307 lines
16 KiB
JavaScript
307 lines
16 KiB
JavaScript
import React, { useState } from 'react'
|
|
import { Head, router } from '@inertiajs/react'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
import AnalyticsNav from './AnalyticsNav'
|
|
|
|
function SummaryCard({ label, value, description }) {
|
|
return (
|
|
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.04] p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
|
<p className="mt-3 text-3xl font-bold text-white">{Number(value || 0).toLocaleString()}</p>
|
|
<p className="mt-3 text-sm leading-6 text-slate-300">{description}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RangeControls({ range }) {
|
|
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
|
const [from, setFrom] = useState(range?.from || '')
|
|
const [to, setTo] = useState(range?.to || '')
|
|
|
|
const visit = (nextRange, nextFrom = from, nextTo = to) => {
|
|
router.get(pathname, {
|
|
range: nextRange,
|
|
...(nextRange === 'custom' ? { from: nextFrom, to: nextTo } : {}),
|
|
}, {
|
|
preserveScroll: true,
|
|
preserveState: true,
|
|
replace: true,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Date Range</p>
|
|
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(range?.options || []).map((option) => {
|
|
const active = option.value === range?.active
|
|
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => visit(option.value)}
|
|
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/30 bg-sky-300/12 text-sky-100' : 'border-white/[0.08] bg-white/[0.04] text-slate-300 hover:border-white/15 hover:bg-white/[0.06] hover:text-white'}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap items-end gap-3 border-t border-white/[0.08] pt-5">
|
|
<label className="flex flex-col gap-2 text-sm text-slate-300">
|
|
<span>From</span>
|
|
<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" />
|
|
</label>
|
|
<label className="flex flex-col gap-2 text-sm text-slate-300">
|
|
<span>To</span>
|
|
<input type="date" value={to} onChange={(event) => setTo(event.target.value)} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" />
|
|
</label>
|
|
<button type="button" onClick={() => visit('custom', from, to)} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.06]">
|
|
Apply Custom Range
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Section({ title, description, children }) {
|
|
return (
|
|
<section className="rounded-[30px] 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">{title}</p>
|
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-300">{description}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-5">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function EmptyState({ text }) {
|
|
return <div className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-8 text-sm text-slate-400">{text}</div>
|
|
}
|
|
|
|
function Badge({ children, tone = 'default' }) {
|
|
const tones = {
|
|
default: 'border-white/[0.08] bg-white/[0.04] text-slate-200',
|
|
high: 'border-rose-300/25 bg-rose-300/10 text-rose-100',
|
|
medium: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
|
|
low: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100',
|
|
}
|
|
|
|
return <span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.default}`}>{children}</span>
|
|
}
|
|
|
|
function Table({ columns, children }) {
|
|
return (
|
|
<div className="overflow-x-auto rounded-[24px] border border-white/[0.08] bg-black/20">
|
|
<table className="min-w-full divide-y divide-white/[0.08] text-left">
|
|
<thead>
|
|
<tr>
|
|
{columns.map((column) => (
|
|
<th key={column} className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{column}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/[0.06]">{children}</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function OpportunityHighlights({ items = [] }) {
|
|
if (!items.length) {
|
|
return <EmptyState text="Recommendations will appear here once Academy analytics has enough activity in the selected range." />
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{items.map((item, index) => (
|
|
<div key={`${item.title}-${index}`} className="rounded-[24px] border border-white/[0.08] bg-black/20 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-base font-semibold text-white">{item.title}</p>
|
|
<Badge tone={item.priority}>{item.priority}</Badge>
|
|
</div>
|
|
<p className="mt-3 text-sm leading-6 text-slate-300">{item.reason}</p>
|
|
<p className="mt-4 text-sm font-semibold text-sky-100">{item.suggested_action}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function AcademyAnalyticsIntelligence({
|
|
nav = [],
|
|
range,
|
|
contentOpportunities = {},
|
|
searchGaps = {},
|
|
promptInsights = {},
|
|
lessonDropoffs = {},
|
|
courseHealth = {},
|
|
premiumInterest = {},
|
|
editorialRecommendations = {},
|
|
}) {
|
|
return (
|
|
<AdminLayout title="Academy Content Intelligence" subtitle="Editorial and business signals from Academy rollups, search demand, engagement, and premium intent.">
|
|
<Head title="Admin · Academy Content Intelligence" />
|
|
|
|
<div className="space-y-6">
|
|
<AnalyticsNav items={nav} />
|
|
<RangeControls range={range} />
|
|
|
|
<Section title="Content Opportunities" description="A fast view of where Academy demand is strongest, where content is underperforming, and which changes should be prioritized next.">
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
|
{(contentOpportunities?.cards || []).map((card) => (
|
|
<SummaryCard key={card.label} label={card.label} value={card.value} description={card.description} />
|
|
))}
|
|
</div>
|
|
<div className="mt-6">
|
|
<OpportunityHighlights items={contentOpportunities?.highlights || []} />
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Search Gaps" description="Queries that suggest missing content, weak relevance, or topics worth expanding because users are clearly engaging with them.">
|
|
{searchGaps?.rows?.length ? (
|
|
<Table columns={['Query', 'Searches', 'Results', 'Clicks', 'CTR', 'Suggested Action']}>
|
|
{searchGaps.rows.map((row) => (
|
|
<tr key={row.normalized_query}>
|
|
<td className="px-4 py-4 align-top">
|
|
<p className="font-semibold text-white">{row.query}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<Badge tone={row.priority}>{row.issue}</Badge>
|
|
{row.logged_in_searches > 1 ? <Badge>Logged-in x{row.logged_in_searches}</Badge> : null}
|
|
{row.subscriber_searches > 0 ? <Badge>Subscribers x{row.subscriber_searches}</Badge> : null}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.searches}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.results_count}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.clicks}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.ctr}%</td>
|
|
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
|
</tr>
|
|
))}
|
|
</Table>
|
|
) : <EmptyState text="No Academy search gaps were detected in this range." />}
|
|
</Section>
|
|
|
|
<Section title="Prompt Insights" description="Signals that show whether prompts need better quality, stronger discoverability, more examples, or a premium follow-up.">
|
|
{promptInsights?.rows?.length ? (
|
|
<Table columns={['Prompt', 'Views', 'Copies', 'Copy Rate', 'Saves', 'Likes', 'Issue', 'Suggested Action']}>
|
|
{promptInsights.rows.map((row) => (
|
|
<tr key={row.content_id}>
|
|
<td className="px-4 py-4 align-top">
|
|
<p className="font-semibold text-white">{row.title}</p>
|
|
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{row.content_type_label}</p>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.prompt_copies}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.copy_rate}%</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.saves}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.likes}</td>
|
|
<td className="px-4 py-4"><Badge tone={row.priority}>{row.issue}</Badge></td>
|
|
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
|
</tr>
|
|
))}
|
|
</Table>
|
|
) : <EmptyState text="No prompt intelligence signals were detected in this range." />}
|
|
</Section>
|
|
|
|
<Section title="Lesson Drop-offs" description="Lessons where users hesitate to start, fail to finish, or unexpectedly show strong premium interest.">
|
|
{lessonDropoffs?.rows?.length ? (
|
|
<Table columns={['Lesson', 'Views', 'Starts', 'Completions', 'Completion Rate', 'Issue', 'Suggested Action']}>
|
|
{lessonDropoffs.rows.map((row) => (
|
|
<tr key={row.content_id}>
|
|
<td className="px-4 py-4 align-top">
|
|
<p className="font-semibold text-white">{row.title}</p>
|
|
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">Start rate {row.start_rate}%</p>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.starts}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.completions}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.completion_rate}%</td>
|
|
<td className="px-4 py-4"><Badge tone={row.priority}>{row.issue}</Badge></td>
|
|
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
|
</tr>
|
|
))}
|
|
</Table>
|
|
) : <EmptyState text="No lesson drop-off signals were detected in this range." />}
|
|
</Section>
|
|
|
|
<Section title="Course Health" description="Courses that need better positioning or restructuring, plus courses that have enough momentum to justify expansion.">
|
|
{courseHealth?.rows?.length ? (
|
|
<Table columns={['Course', 'Views', 'Starts', 'Completions', 'Completion Rate', 'Avg Progress', 'Suggested Action']}>
|
|
{courseHealth.rows.map((row) => (
|
|
<tr key={row.content_id}>
|
|
<td className="px-4 py-4 align-top">
|
|
<p className="font-semibold text-white">{row.title}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<Badge tone={row.priority}>{row.issue}</Badge>
|
|
{row.learners > 0 ? <Badge>Learners {row.learners}</Badge> : null}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.starts}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.completions}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.completion_rate}%</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.avg_progress}%</td>
|
|
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
|
</tr>
|
|
))}
|
|
</Table>
|
|
) : <EmptyState text="No course health signals were detected in this range." />}
|
|
</Section>
|
|
|
|
<Section title="Premium Interest" description="Free and premium Academy content that either converts well into upgrade intent or needs stronger teaser positioning.">
|
|
{premiumInterest?.rows?.length ? (
|
|
<Table columns={['Content', 'Type', 'Premium Views', 'Upgrade Clicks', 'Upgrade Rate', 'Suggested Action']}>
|
|
{premiumInterest.rows.map((row) => (
|
|
<tr key={`${row.content_type}-${row.content_id}`}>
|
|
<td className="px-4 py-4 align-top">
|
|
<p className="font-semibold text-white">{row.title}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<Badge tone={row.priority}>{row.issue}</Badge>
|
|
<Badge>Interest score {row.premium_interest_score}</Badge>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.content_type_label}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.premium_preview_views}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.upgrade_clicks}</td>
|
|
<td className="px-4 py-4 text-sm text-slate-200">{row.upgrade_rate}%</td>
|
|
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
|
</tr>
|
|
))}
|
|
</Table>
|
|
) : <EmptyState text="No premium interest signals were detected in this range." />}
|
|
</Section>
|
|
|
|
<Section title="Editorial Recommendations" description="Prioritized recommendations that combine content demand, user behavior, and premium intent into concrete next actions.">
|
|
{editorialRecommendations?.rows?.length ? (
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{editorialRecommendations.rows.map((row, index) => (
|
|
<div key={`${row.title}-${index}`} className="rounded-[24px] border border-white/[0.08] bg-black/20 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-base font-semibold text-white">{row.title}</p>
|
|
<Badge tone={row.priority}>{row.priority}</Badge>
|
|
</div>
|
|
<p className="mt-3 text-sm leading-6 text-slate-300">{row.description}</p>
|
|
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-300">{row.reason}</p>
|
|
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Suggested Action</p>
|
|
<p className="mt-2 text-sm font-semibold leading-6 text-sky-100">{row.suggested_action}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : <EmptyState text="No editorial recommendations were generated for this range yet." />}
|
|
</Section>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
} |