Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ ChartJS.register(
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
statsRange?: '7d' | '30d' | '90d'
|
||||
onRangeChange?: (range: '7d' | '30d' | '90d') => void
|
||||
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
|
||||
formatDate: (date: string | null) => string
|
||||
maskIp: (ip: string) => string
|
||||
@@ -51,6 +53,8 @@ interface Props {
|
||||
export function AnalyticsPanel({
|
||||
stats,
|
||||
statsStatus,
|
||||
statsRange = '30d',
|
||||
onRangeChange,
|
||||
opsStatus,
|
||||
formatDate,
|
||||
maskIp,
|
||||
@@ -224,6 +228,9 @@ export function AnalyticsPanel({
|
||||
},
|
||||
}
|
||||
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Analytics">
|
||||
<div className="admin-panel-head">
|
||||
@@ -231,22 +238,51 @@ export function AnalyticsPanel({
|
||||
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
</div>
|
||||
|
||||
{/* Time-range tabs */}
|
||||
{onRangeChange && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{(['7d', '30d', '90d'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onRangeChange(r)}
|
||||
style={{
|
||||
padding: '0.35rem 1rem',
|
||||
borderRadius: '20px',
|
||||
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
|
||||
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
|
||||
color: statsRange === r ? '#c9a84c' : '#7a7060',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: statsRange === r ? 600 : 400,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
|
||||
Showing: {rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{stats.totalHits.toLocaleString()}</p>
|
||||
<small>All page requests</small>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>{rangeLabel} all requests</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
@@ -496,6 +532,38 @@ export function AnalyticsPanel({
|
||||
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Study Enrollment Funnel */}
|
||||
{stats.studyEnrollment?.funnel && (
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Study Enrollment Funnel</h2>
|
||||
<p>How many users progress from signup → first visit → first section completed.</p>
|
||||
</div>
|
||||
<div className="admin-funnel">
|
||||
{(() => {
|
||||
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
|
||||
const steps = [
|
||||
{ label: 'Signed Up', count: signups, pct: 100 },
|
||||
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
|
||||
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
|
||||
]
|
||||
return steps.map((step, i) => (
|
||||
<div key={i} className="admin-funnel-step">
|
||||
<div className="admin-funnel-bar-wrap">
|
||||
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
|
||||
</div>
|
||||
<div className="admin-funnel-label">
|
||||
<span className="admin-funnel-step-name">{step.label}</span>
|
||||
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
|
||||
<span className="admin-funnel-pct">{step.pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deployment & Cache Status */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
|
||||
Reference in New Issue
Block a user