Files
SkinbaseNova/resources/js/Pages/Admin/Adsense/Index.jsx
T
test 4d60a876af Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
2026-09-20 14:49:48 +02:00

402 lines
17 KiB
React

import React, { useMemo, useState } from 'react'
import { Head, router, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—'
}
return Number(value).toLocaleString()
}
function formatMoney(value, currency) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—'
}
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 2,
}).format(Number(value))
} catch {
return `${Number(value).toFixed(2)} ${currency || ''}`.trim()
}
}
function formatPercent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—'
}
return `${(Number(value) * 100).toFixed(2)}%`
}
function formatKpi(kpi) {
if (kpi.format === 'money') {
return formatMoney(kpi.value, kpi.currency_code)
}
if (kpi.format === 'percent') {
return formatPercent(kpi.value)
}
return formatNumber(kpi.value)
}
function formatChange(change) {
if (change === null || change === undefined) {
return 'No previous period'
}
const prefix = Number(change) > 0 ? '+' : ''
return `${prefix}${Number(change).toFixed(1)}% vs previous`
}
function formatTimestamp(value) {
if (!value) {
return 'Never'
}
return new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
function statusCopy(connection) {
if (!connection) {
return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' }
}
if (connection.needs_reconnect || connection.status === 'reconnect_required') {
return { label: 'Reconnect required', className: 'border-amber-400/20 bg-amber-500/10 text-amber-200' }
}
if (connection.status === 'error') {
return { label: 'Synchronization error', className: 'border-rose-400/20 bg-rose-500/10 text-rose-200' }
}
if (connection.status === 'connected') {
return { label: 'Connected', className: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' }
}
if (connection.status === 'pending') {
return { label: 'Pending account selection', className: 'border-sky-400/20 bg-sky-500/10 text-sky-200' }
}
return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' }
}
function Sparkline({ points, metric }) {
if (!points.length) {
return <p className="px-4 py-10 text-sm text-slate-500">No chart data for this range.</p>
}
const values = points.map((point) => Number(point[metric] || 0))
const max = Math.max(...values, 0.0001)
const width = 640
const height = 180
const step = values.length === 1 ? width : width / (values.length - 1)
const path = values
.map((value, index) => {
const x = index * step
const y = height - (value / max) * (height - 16) - 8
return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${width} ${height}`} className="h-48 w-full text-rose-300" role="img" aria-label="AdSense trend">
<path d={path} fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SortableTable({ rows, currency }) {
const [sortKey, setSortKey] = useState('revenue')
const [direction, setDirection] = useState('desc')
const sorted = useMemo(() => {
return [...rows].sort((a, b) => {
const left = a[sortKey]
const right = b[sortKey]
const numeric = typeof left === 'number' || typeof right === 'number' || sortKey !== 'label'
if (numeric) {
return direction === 'asc' ? Number(left || 0) - Number(right || 0) : Number(right || 0) - Number(left || 0)
}
return direction === 'asc'
? String(left || '').localeCompare(String(right || ''))
: String(right || '').localeCompare(String(left || ''))
})
}, [rows, sortKey, direction])
const toggle = (key) => {
if (sortKey === key) {
setDirection(direction === 'asc' ? 'desc' : 'asc')
return
}
setSortKey(key)
setDirection(key === 'label' ? 'asc' : 'desc')
}
const header = (key, label) => (
<button type="button" onClick={() => toggle(key)} className="font-semibold uppercase tracking-[0.16em] text-slate-500">
{label}
</button>
)
if (!rows.length) {
return <p className="px-4 py-8 text-sm text-slate-500">No rows for this range.</p>
}
return (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-white/10">
<th className="px-4 py-3">{header('label', 'Placement')}</th>
<th className="px-4 py-3">{header('page_views', 'Page Views')}</th>
<th className="px-4 py-3">{header('impressions', 'Impressions')}</th>
<th className="px-4 py-3">{header('clicks', 'Clicks')}</th>
<th className="px-4 py-3">{header('ctr', 'CTR')}</th>
<th className="px-4 py-3">{header('rpm', 'RPM')}</th>
<th className="px-4 py-3">{header('revenue', 'Revenue')}</th>
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={row.key} className="border-b border-white/5 text-slate-200">
<td className="px-4 py-3 font-medium text-white">{row.label}</td>
<td className="px-4 py-3">{formatNumber(row.page_views)}</td>
<td className="px-4 py-3">{formatNumber(row.impressions)}</td>
<td className="px-4 py-3">{formatNumber(row.clicks)}</td>
<td className="px-4 py-3">{formatPercent(row.ctr)}</td>
<td className="px-4 py-3">{formatMoney(row.rpm, currency)}</td>
<td className="px-4 py-3">{formatMoney(row.revenue, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function AdsenseIndex({
connection,
configured,
pendingAccounts = [],
recommendedPlacements = [],
range,
dashboard,
routes,
}) {
const flash = usePage().props.flash ?? {}
const [chartMetric, setChartMetric] = useState('estimated_earnings')
const status = statusCopy(connection)
const query = new URLSearchParams()
query.set('period', range.period)
if (range.period === 'custom') {
query.set('from', range.from)
query.set('to', range.to)
}
const applyPeriod = (period, from = null, to = null) => {
const params = { period }
if (period === 'custom' && from && to) {
params.from = from
params.to = to
}
router.get(routes.index, params, { preserveState: true, replace: true })
}
return (
<AdminLayout title="AdSense Analytics" subtitle="Read-only Google AdSense reporting for Skinbase administrators.">
<Head title="Admin · AdSense Analytics" />
<div className="space-y-6">
{flash.success ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div>
) : null}
{flash.warning ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">{flash.warning}</div>
) : null}
{flash.error ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Google AdSense</p>
<div className="mt-3 flex items-center gap-3">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${status.className}`}>
{status.label}
</span>
</div>
<p className="mt-4 text-sm text-slate-300">
Account: {connection?.account_display_name || 'Not selected'}
{connection?.account_resource_name ? ` / ${connection.account_resource_name.replace('accounts/', '')}` : ''}
</p>
<p className="mt-1 text-sm text-slate-400">Last synchronized: {formatTimestamp(connection?.last_successful_sync_at)}</p>
{connection?.last_error ? <p className="mt-2 text-sm text-rose-200">{connection.last_error}</p> : null}
{connection?.needs_reconnect ? (
<p className="mt-2 text-sm text-amber-200">AdSense authorization expired or was revoked. Reconnect Google AdSense.</p>
) : null}
{!configured ? <p className="mt-2 text-sm text-amber-200">ADSENSE_CLIENT_ID and ADSENSE_CLIENT_SECRET are not configured.</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<a href={routes.connect} className="rounded-xl border border-white/10 bg-rose-500/20 px-4 py-2 text-sm font-semibold text-rose-100 hover:bg-rose-500/30">
{connection?.needs_reconnect || connection?.status === 'reconnect_required' ? 'Reconnect' : 'Connect Google AdSense'}
</a>
{connection?.status === 'connected' ? (
<button type="button" onClick={() => router.post(routes.sync)} className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">
Sync now
</button>
) : null}
{connection && connection.status !== 'disconnected' ? (
<button type="button" onClick={() => router.delete(routes.disconnect)} className="rounded-xl border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-300 hover:bg-black/40">
Disconnect
</button>
) : null}
</div>
</div>
</section>
{pendingAccounts.length ? (
<section className="rounded-[28px] border border-amber-400/20 bg-amber-500/5 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-200">Select AdSense account</p>
<div className="mt-4 space-y-2">
{pendingAccounts.map((account) => (
<button
key={account.resource_name}
type="button"
onClick={() => router.post(routes.account, { account_resource_name: account.resource_name })}
className="block w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-left text-sm text-white hover:border-white/20"
>
<span className="font-semibold">{account.display_name}</span>
<span className="ml-2 text-slate-400">{account.resource_name.replace('accounts/', '')}</span>
</button>
))}
</div>
</section>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Period</p>
<p className="mt-2 text-sm text-slate-300">{range.from} to {range.to}</p>
</div>
<div className="flex flex-wrap gap-2">
{[
['today', 'Today'],
['yesterday', 'Yesterday'],
['7d', 'Last 7 days'],
['30d', 'Last 30 days'],
].map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => applyPeriod(value)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${range.period === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400 hover:text-white'}`}
>
{label}
</button>
))}
</div>
</div>
<form
className="mt-4 flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
applyPeriod('custom', form.get('from'), form.get('to'))
}}
>
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
From
<input type="date" name="from" defaultValue={range.from} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
</label>
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
To
<input type="date" name="to" defaultValue={range.to} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
</label>
<button type="submit" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white">
Custom range
</button>
</form>
<p className="mt-4 text-xs text-slate-500">Today's AdSense data is estimated and may change.</p>
</section>
{dashboard ? (
<>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{dashboard.kpis.map((kpi) => (
<div key={kpi.key} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{kpi.label}</p>
<p className="mt-3 text-2xl font-bold text-white">{formatKpi(kpi)}</p>
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">{formatChange(kpi.change_percent)}</p>
</div>
))}
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Daily trend</p>
<div className="flex flex-wrap gap-2">
{[
['estimated_earnings', 'Revenue'],
['page_views', 'Page Views'],
['page_views_rpm', 'Page RPM'],
['clicks', 'Clicks'],
].map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setChartMetric(value)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${chartMetric === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400'}`}
>
{label}
</button>
))}
</div>
</div>
<Sparkline points={dashboard.chart || []} metric={chartMetric} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Placement performance</p>
<SortableTable rows={dashboard.placements || []} currency={dashboard.currency_code} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Platform breakdown</p>
<SortableTable rows={dashboard.platforms || []} currency={dashboard.currency_code} />
</section>
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Top countries by revenue</p>
<SortableTable rows={dashboard.countries || []} currency={dashboard.currency_code} />
</section>
</>
) : null}
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Recommended AdSense unit names</p>
<p className="mt-2 text-sm text-slate-400">Rename units inside Google AdSense. Skinbase imports names and does not modify inventory.</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{recommendedPlacements.map((name) => (
<li key={name} className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-slate-200">{name}</li>
))}
</ul>
</section>
</div>
</AdminLayout>
)
}