mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
Custom admin view at /admin/youtube-analytics with 4 tabs: - Performance: Views, Watch Time, CTR, Subscribers with period comparison - Pipeline: Status distribution, scheduled videos, overdue tasks - Goals: Monthly target progress bars and custom KPIs - Community: Sentiment analysis, response time, top topics Includes channel selector, period selector (7d/30d/90d), and sidebar nav link in the YouTube section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
971 lines
28 KiB
Markdown
971 lines
28 KiB
Markdown
# YouTube Analytics Dashboard Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a custom admin view at `/admin/youtube-analytics` with 4 tabs (Performance, Pipeline, Goals, Community), channel/period selectors, and a sidebar nav link.
|
|
|
|
**Architecture:** Single client component with tab navigation. One API route serves all tabs via `?tab=X` query parameter, loading data lazily per tab switch. Follows the TenantDashboard pattern (BEM CSS, Payload variables, `useTenantSelection()`).
|
|
|
|
**Tech Stack:** React 19, Next.js 16, Payload CMS 3.76.1, SCSS (BEM), Payload CSS variables
|
|
|
|
**Design doc:** `docs/plans/2026-02-13-youtube-analytics-dashboard-design.md`
|
|
|
|
---
|
|
|
|
### Task 1: API Route — Performance Tab
|
|
|
|
**Files:**
|
|
- Create: `src/app/(payload)/api/youtube/analytics/route.ts`
|
|
|
|
**Step 1: Create the API route with performance tab handler**
|
|
|
|
```typescript
|
|
// src/app/(payload)/api/youtube/analytics/route.ts
|
|
|
|
import { getPayload } from 'payload'
|
|
import config from '@payload-config'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { subDays } from 'date-fns'
|
|
|
|
interface UserWithYouTubeRole {
|
|
id: number
|
|
isSuperAdmin?: boolean
|
|
youtubeRole?: string
|
|
}
|
|
|
|
function getPeriodDates(period: string) {
|
|
const now = new Date()
|
|
switch (period) {
|
|
case '7d':
|
|
return { start: subDays(now, 7), prevStart: subDays(now, 14), prevEnd: subDays(now, 7) }
|
|
case '90d':
|
|
return { start: subDays(now, 90), prevStart: subDays(now, 180), prevEnd: subDays(now, 90) }
|
|
default:
|
|
return { start: subDays(now, 30), prevStart: subDays(now, 60), prevEnd: subDays(now, 30) }
|
|
}
|
|
}
|
|
|
|
async function getPerformanceData(payload: any, channel: string, period: string) {
|
|
const { start, prevStart, prevEnd } = getPeriodDates(period)
|
|
|
|
// Build where clause for published videos with performance data
|
|
const where: Record<string, unknown> = {
|
|
status: { equals: 'published' },
|
|
actualPublishDate: { greater_than_equal: start.toISOString() },
|
|
}
|
|
if (channel !== 'all') {
|
|
where.channel = { equals: parseInt(channel) }
|
|
}
|
|
|
|
// Previous period where clause
|
|
const prevWhere: Record<string, unknown> = {
|
|
status: { equals: 'published' },
|
|
actualPublishDate: {
|
|
greater_than_equal: prevStart.toISOString(),
|
|
less_than: prevEnd.toISOString(),
|
|
},
|
|
}
|
|
if (channel !== 'all') {
|
|
prevWhere.channel = { equals: parseInt(channel) }
|
|
}
|
|
|
|
const [current, previous] = await Promise.all([
|
|
payload.find({
|
|
collection: 'youtube-content',
|
|
where,
|
|
limit: 100,
|
|
depth: 0,
|
|
sort: '-performance.views',
|
|
}),
|
|
payload.find({
|
|
collection: 'youtube-content',
|
|
where: prevWhere,
|
|
limit: 100,
|
|
depth: 0,
|
|
}),
|
|
])
|
|
|
|
const docs = current.docs
|
|
const prevDocs = previous.docs
|
|
|
|
// Aggregate current period
|
|
let totalViews = 0, totalWatchTime = 0, totalCtr = 0, totalSubscribers = 0
|
|
let totalLikes = 0, totalComments = 0, totalShares = 0
|
|
let ctrCount = 0
|
|
|
|
for (const doc of docs) {
|
|
const p = doc.performance || {}
|
|
totalViews += p.views || 0
|
|
totalWatchTime += p.watchTimeMinutes || 0
|
|
if (p.ctr != null) { totalCtr += p.ctr; ctrCount++ }
|
|
totalSubscribers += p.subscribersGained || 0
|
|
totalLikes += p.likes || 0
|
|
totalComments += p.comments || 0
|
|
totalShares += p.shares || 0
|
|
}
|
|
|
|
// Aggregate previous period
|
|
let prevViews = 0, prevWatchTime = 0, prevSubscribers = 0
|
|
for (const doc of prevDocs) {
|
|
const p = doc.performance || {}
|
|
prevViews += p.views || 0
|
|
prevWatchTime += p.watchTimeMinutes || 0
|
|
prevSubscribers += p.subscribersGained || 0
|
|
}
|
|
|
|
// Top 5 and Bottom 5
|
|
const sorted = [...docs].sort((a, b) => (b.performance?.views || 0) - (a.performance?.views || 0))
|
|
const mapVideo = (d: any) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
thumbnailText: d.thumbnailText || '',
|
|
views: d.performance?.views || 0,
|
|
ctr: d.performance?.ctr || 0,
|
|
avgRetention: d.performance?.avgViewPercentage || 0,
|
|
likes: d.performance?.likes || 0,
|
|
})
|
|
|
|
return {
|
|
stats: {
|
|
totalViews,
|
|
totalWatchTimeHours: Math.round((totalWatchTime / 60) * 10) / 10,
|
|
avgCtr: ctrCount > 0 ? Math.round((totalCtr / ctrCount) * 100) / 100 : 0,
|
|
subscribersGained: totalSubscribers,
|
|
},
|
|
comparison: {
|
|
views: prevViews > 0 ? Math.round(((totalViews - prevViews) / prevViews) * 100) : 0,
|
|
watchTime: prevWatchTime > 0 ? Math.round(((totalWatchTime - prevWatchTime) / prevWatchTime) * 100) : 0,
|
|
subscribers: prevSubscribers > 0 ? Math.round(((totalSubscribers - prevSubscribers) / prevSubscribers) * 100) : 0,
|
|
},
|
|
topVideos: sorted.slice(0, 5).map(mapVideo),
|
|
bottomVideos: sorted.length > 5 ? sorted.slice(-5).reverse().map(mapVideo) : [],
|
|
engagement: { likes: totalLikes, comments: totalComments, shares: totalShares },
|
|
}
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
const payload = await getPayload({ config })
|
|
const { user } = await payload.auth({ headers: req.headers })
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const typedUser = user as UserWithYouTubeRole
|
|
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
|
|
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
|
|
}
|
|
|
|
const { searchParams } = new URL(req.url)
|
|
const tab = searchParams.get('tab') || 'performance'
|
|
const channel = searchParams.get('channel') || 'all'
|
|
const period = searchParams.get('period') || '30d'
|
|
|
|
// Channels always needed for selector
|
|
const channels = await payload.find({
|
|
collection: 'youtube-channels',
|
|
where: { status: { equals: 'active' } },
|
|
depth: 0,
|
|
limit: 50,
|
|
})
|
|
|
|
let tabData: unknown = {}
|
|
|
|
switch (tab) {
|
|
case 'performance':
|
|
tabData = await getPerformanceData(payload, channel, period)
|
|
break
|
|
// Pipeline, Goals, Community added in subsequent tasks
|
|
default:
|
|
tabData = {}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
tab,
|
|
channel,
|
|
period,
|
|
channels: channels.docs.map((c: any) => ({ id: c.id, name: c.name, slug: c.slug })),
|
|
data: tabData,
|
|
})
|
|
} catch (error) {
|
|
console.error('[YouTube Analytics] Error:', error)
|
|
return NextResponse.json(
|
|
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
```
|
|
|
|
**Step 2: Verify route loads**
|
|
|
|
Run: `curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/api/youtube/analytics`
|
|
Expected: 401 (no auth) — confirms route is registered
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/\(payload\)/api/youtube/analytics/route.ts
|
|
git commit -m "feat(yt-analytics): add API route with performance tab"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: API Route — Pipeline, Goals, Community Tabs
|
|
|
|
**Files:**
|
|
- Modify: `src/app/(payload)/api/youtube/analytics/route.ts`
|
|
|
|
**Step 1: Add pipeline tab handler**
|
|
|
|
Add this function before the `GET` handler:
|
|
|
|
```typescript
|
|
async function getPipelineData(payload: any, channel: string) {
|
|
const channelFilter: Record<string, unknown> = channel !== 'all'
|
|
? { channel: { equals: parseInt(channel) } }
|
|
: {}
|
|
|
|
// Pipeline status counts (parallel)
|
|
const statuses = ['idea', 'script_draft', 'script_review', 'script_approved',
|
|
'shoot_scheduled', 'shot', 'rough_cut', 'fine_cut', 'final_review',
|
|
'approved', 'upload_scheduled', 'published']
|
|
|
|
const counts = await Promise.all(
|
|
statuses.map((s) =>
|
|
payload.count({
|
|
collection: 'youtube-content',
|
|
where: { status: { equals: s }, ...channelFilter },
|
|
}),
|
|
),
|
|
)
|
|
|
|
const pipeline: Record<string, number> = {}
|
|
statuses.forEach((s, i) => { pipeline[s] = counts[i].totalDocs })
|
|
|
|
// In production count (everything between idea and published exclusive)
|
|
const inProduction = Object.entries(pipeline)
|
|
.filter(([k]) => k !== 'published')
|
|
.reduce((sum, [, v]) => sum + v, 0)
|
|
|
|
// This week scheduled
|
|
const weekStart = new Date()
|
|
weekStart.setHours(0, 0, 0, 0)
|
|
const weekEnd = new Date(weekStart)
|
|
weekEnd.setDate(weekEnd.getDate() + 7)
|
|
|
|
const weekWhere: Record<string, unknown> = {
|
|
scheduledPublishDate: {
|
|
greater_than_equal: weekStart.toISOString(),
|
|
less_than: weekEnd.toISOString(),
|
|
},
|
|
...channelFilter,
|
|
}
|
|
|
|
const [thisWeek, overdueTasks, pendingApprovals] = await Promise.all([
|
|
payload.find({
|
|
collection: 'youtube-content',
|
|
where: weekWhere,
|
|
sort: 'scheduledPublishDate',
|
|
depth: 1,
|
|
limit: 20,
|
|
}),
|
|
payload.find({
|
|
collection: 'yt-tasks',
|
|
where: {
|
|
and: [
|
|
{ status: { not_in: ['done', 'cancelled'] } },
|
|
{ dueDate: { less_than: new Date().toISOString() } },
|
|
...(channel !== 'all' ? [{ channel: { equals: parseInt(channel) } }] : []),
|
|
],
|
|
},
|
|
depth: 1,
|
|
limit: 20,
|
|
sort: 'dueDate',
|
|
}),
|
|
payload.count({
|
|
collection: 'youtube-content',
|
|
where: {
|
|
status: { in: ['script_review', 'final_review'] },
|
|
...channelFilter,
|
|
},
|
|
}),
|
|
])
|
|
|
|
return {
|
|
stats: {
|
|
inProduction,
|
|
thisWeekCount: thisWeek.totalDocs,
|
|
overdueTasksCount: overdueTasks.totalDocs,
|
|
pendingApprovals: pendingApprovals.totalDocs,
|
|
},
|
|
pipeline,
|
|
thisWeekVideos: thisWeek.docs.map((d: any) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
status: d.status,
|
|
scheduledPublishDate: d.scheduledPublishDate,
|
|
channel: typeof d.channel === 'object' ? d.channel?.name : d.channel,
|
|
})),
|
|
overdueTasks: overdueTasks.docs.map((d: any) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
dueDate: d.dueDate,
|
|
assignedTo: typeof d.assignedTo === 'object' ? d.assignedTo?.email : d.assignedTo,
|
|
video: typeof d.video === 'object' ? d.video?.title : null,
|
|
})),
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Add goals tab handler**
|
|
|
|
```typescript
|
|
async function getGoalsData(payload: any, channel: string) {
|
|
// Find current month's goals
|
|
const now = new Date()
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
|
|
|
const where: Record<string, unknown> = {
|
|
month: {
|
|
greater_than_equal: monthStart.toISOString(),
|
|
less_than_equal: monthEnd.toISOString(),
|
|
},
|
|
}
|
|
if (channel !== 'all') {
|
|
where.channel = { equals: parseInt(channel) }
|
|
}
|
|
|
|
const goals = await payload.find({
|
|
collection: 'yt-monthly-goals',
|
|
where,
|
|
depth: 1,
|
|
limit: 10,
|
|
})
|
|
|
|
if (goals.docs.length === 0) {
|
|
return { stats: { contentProgress: 0, subscriberProgress: 0, viewsProgress: 0, overall: 0 }, goals: [], customGoals: [] }
|
|
}
|
|
|
|
// Aggregate across all matching goals (could be multiple channels)
|
|
let totalContentTarget = 0, totalContentCurrent = 0
|
|
let totalSubsTarget = 0, totalSubsCurrent = 0
|
|
let totalViewsTarget = 0, totalViewsCurrent = 0
|
|
const allCustomGoals: any[] = []
|
|
|
|
for (const goal of goals.docs) {
|
|
const c = goal.contentGoals || {}
|
|
totalContentTarget += (c.longformsTarget || 0) + (c.shortsTarget || 0)
|
|
totalContentCurrent += (c.longformsCurrent || 0) + (c.shortsCurrent || 0)
|
|
|
|
const a = goal.audienceGoals || {}
|
|
totalSubsTarget += a.subscribersTarget || 0
|
|
totalSubsCurrent += a.subscribersCurrent || 0
|
|
totalViewsTarget += a.viewsTarget || 0
|
|
totalViewsCurrent += a.viewsCurrent || 0
|
|
|
|
if (goal.customGoals) {
|
|
for (const cg of goal.customGoals) {
|
|
allCustomGoals.push({
|
|
metric: cg.metric,
|
|
target: cg.target,
|
|
current: cg.current,
|
|
status: cg.status,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const pct = (current: number, target: number) => target > 0 ? Math.round((current / target) * 100) : 0
|
|
const contentPct = pct(totalContentCurrent, totalContentTarget)
|
|
const subsPct = pct(totalSubsCurrent, totalSubsTarget)
|
|
const viewsPct = pct(totalViewsCurrent, totalViewsTarget)
|
|
const overall = Math.round((contentPct + subsPct + viewsPct) / 3)
|
|
|
|
return {
|
|
stats: {
|
|
contentProgress: contentPct,
|
|
contentLabel: `${totalContentCurrent}/${totalContentTarget}`,
|
|
subscriberProgress: subsPct,
|
|
subscriberLabel: `${totalSubsCurrent}/${totalSubsTarget}`,
|
|
viewsProgress: viewsPct,
|
|
viewsLabel: `${totalViewsCurrent}/${totalViewsTarget}`,
|
|
overall,
|
|
},
|
|
goals: goals.docs.map((g: any) => ({
|
|
id: g.id,
|
|
channel: typeof g.channel === 'object' ? g.channel?.name : g.channel,
|
|
month: g.month,
|
|
contentGoals: g.contentGoals,
|
|
audienceGoals: g.audienceGoals,
|
|
engagementGoals: g.engagementGoals,
|
|
businessGoals: g.businessGoals,
|
|
notes: g.notes,
|
|
})),
|
|
customGoals: allCustomGoals,
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Add community tab handler**
|
|
|
|
```typescript
|
|
async function getCommunityData(payload: any, channel: string, period: string) {
|
|
const { start } = getPeriodDates(period)
|
|
|
|
const where: Record<string, unknown> = {
|
|
publishedAt: { greater_than_equal: start.toISOString() },
|
|
}
|
|
if (channel !== 'all') {
|
|
where.socialAccount = { equals: parseInt(channel) }
|
|
}
|
|
|
|
const [interactions, unresolved, escalations] = await Promise.all([
|
|
payload.find({
|
|
collection: 'community-interactions',
|
|
where,
|
|
limit: 500,
|
|
depth: 0,
|
|
}),
|
|
payload.count({
|
|
collection: 'community-interactions',
|
|
where: { ...where, status: { equals: 'new' } },
|
|
}),
|
|
payload.count({
|
|
collection: 'community-interactions',
|
|
where: { ...where, 'flags.requiresEscalation': { equals: true }, status: { not_equals: 'resolved' } },
|
|
}),
|
|
])
|
|
|
|
const docs = interactions.docs
|
|
|
|
// Sentiment distribution
|
|
const sentiment = { positive: 0, neutral: 0, negative: 0, question: 0 }
|
|
for (const d of docs) {
|
|
const s = d.analysis?.sentiment as string
|
|
if (s && s in sentiment) sentiment[s as keyof typeof sentiment]++
|
|
}
|
|
const total = docs.length
|
|
const positivePct = total > 0 ? Math.round((sentiment.positive / total) * 100) : 0
|
|
|
|
// Avg response time (hours)
|
|
let totalResponseTime = 0, responseCount = 0
|
|
for (const d of docs) {
|
|
if (d.response?.sentAt && d.publishedAt) {
|
|
const diff = new Date(d.response.sentAt as string).getTime() - new Date(d.publishedAt as string).getTime()
|
|
totalResponseTime += diff / (1000 * 60 * 60)
|
|
responseCount++
|
|
}
|
|
}
|
|
const avgResponseHours = responseCount > 0 ? Math.round(totalResponseTime / responseCount * 10) / 10 : 0
|
|
|
|
// Top topics
|
|
const topicMap = new Map<string, number>()
|
|
for (const d of docs) {
|
|
const topics = d.analysis?.topics as string[] | undefined
|
|
if (topics) {
|
|
for (const t of topics) { topicMap.set(t, (topicMap.get(t) || 0) + 1) }
|
|
}
|
|
}
|
|
const topTopics = [...topicMap.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([topic, count]) => ({ topic, count }))
|
|
|
|
// Recent unresolved (newest 5)
|
|
const recent = await payload.find({
|
|
collection: 'community-interactions',
|
|
where: { ...where, status: { equals: 'new' } },
|
|
sort: '-publishedAt',
|
|
limit: 5,
|
|
depth: 0,
|
|
})
|
|
|
|
return {
|
|
stats: {
|
|
unresolvedCount: unresolved.totalDocs,
|
|
positivePct,
|
|
avgResponseHours,
|
|
escalationsCount: escalations.totalDocs,
|
|
},
|
|
sentiment,
|
|
topTopics,
|
|
recentUnresolved: recent.docs.map((d: any) => ({
|
|
id: d.id,
|
|
message: (d.message as string || '').slice(0, 120),
|
|
authorName: d.author?.name || 'Unknown',
|
|
authorHandle: d.author?.handle || '',
|
|
publishedAt: d.publishedAt,
|
|
platform: d.platform,
|
|
sentiment: d.analysis?.sentiment,
|
|
})),
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Wire all tabs into the switch statement**
|
|
|
|
In the `GET` handler, replace the switch:
|
|
|
|
```typescript
|
|
switch (tab) {
|
|
case 'performance':
|
|
tabData = await getPerformanceData(payload, channel, period)
|
|
break
|
|
case 'pipeline':
|
|
tabData = await getPipelineData(payload, channel)
|
|
break
|
|
case 'goals':
|
|
tabData = await getGoalsData(payload, channel)
|
|
break
|
|
case 'community':
|
|
tabData = await getCommunityData(payload, channel, period)
|
|
break
|
|
default:
|
|
tabData = await getPerformanceData(payload, channel, period)
|
|
}
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/app/\(payload\)/api/youtube/analytics/route.ts
|
|
git commit -m "feat(yt-analytics): add pipeline, goals, community tab handlers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Nav Link Component
|
|
|
|
**Files:**
|
|
- Create: `src/components/admin/YouTubeAnalyticsNavLinks.tsx`
|
|
- Create: `src/components/admin/YouTubeAnalyticsNavLinks.scss`
|
|
|
|
**Step 1: Create nav link component**
|
|
|
|
Follow the exact pattern from `CommunityNavLinks.tsx`:
|
|
|
|
```typescript
|
|
// src/components/admin/YouTubeAnalyticsNavLinks.tsx
|
|
'use client'
|
|
|
|
import React from 'react'
|
|
import Link from 'next/link'
|
|
import { usePathname } from 'next/navigation'
|
|
|
|
import './YouTubeAnalyticsNavLinks.scss'
|
|
|
|
export const YouTubeAnalyticsNavLinks: React.FC = () => {
|
|
const pathname = usePathname()
|
|
|
|
const links = [
|
|
{
|
|
href: '/admin/youtube-analytics',
|
|
label: 'YouTube Analytics',
|
|
icon: '📈',
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="yt-nav-links">
|
|
<div className="yt-nav-links__header">
|
|
<span className="yt-nav-links__icon">🎬</span>
|
|
<span className="yt-nav-links__title">YouTube</span>
|
|
</div>
|
|
<nav className="yt-nav-links__list">
|
|
{links.map((link) => {
|
|
const isActive = pathname === link.href
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className={`yt-nav-links__link ${isActive ? 'yt-nav-links__link--active' : ''}`}
|
|
>
|
|
<span className="yt-nav-links__link-icon">{link.icon}</span>
|
|
<span className="yt-nav-links__link-label">{link.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default YouTubeAnalyticsNavLinks
|
|
```
|
|
|
|
**Step 2: Create nav link SCSS**
|
|
|
|
```scss
|
|
// src/components/admin/YouTubeAnalyticsNavLinks.scss
|
|
.yt-nav-links {
|
|
padding: 0 var(--base);
|
|
margin-top: var(--base);
|
|
border-top: 1px solid var(--theme-elevation-100);
|
|
padding-top: var(--base);
|
|
|
|
&__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--theme-elevation-500);
|
|
}
|
|
|
|
&__icon {
|
|
font-size: 14px;
|
|
}
|
|
|
|
&__title {
|
|
flex: 1;
|
|
}
|
|
|
|
&__list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
&__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
color: var(--theme-elevation-800);
|
|
font-size: 13px;
|
|
transition: all 0.15s ease;
|
|
|
|
&:hover {
|
|
background-color: var(--theme-elevation-50);
|
|
color: var(--theme-elevation-900);
|
|
}
|
|
|
|
&--active {
|
|
background-color: var(--theme-elevation-100);
|
|
color: var(--theme-text);
|
|
font-weight: 500;
|
|
|
|
&:hover {
|
|
background-color: var(--theme-elevation-150);
|
|
}
|
|
}
|
|
}
|
|
|
|
&__link-icon {
|
|
font-size: 16px;
|
|
width: 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
&__link-label {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
[data-theme='dark'] {
|
|
.yt-nav-links {
|
|
&__link {
|
|
&:hover {
|
|
background-color: var(--theme-elevation-100);
|
|
}
|
|
|
|
&--active {
|
|
background-color: var(--theme-elevation-150);
|
|
|
|
&:hover {
|
|
background-color: var(--theme-elevation-200);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/admin/YouTubeAnalyticsNavLinks.tsx src/components/admin/YouTubeAnalyticsNavLinks.scss
|
|
git commit -m "feat(yt-analytics): add sidebar nav link component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: View Wrapper + Registration in payload.config.ts
|
|
|
|
**Files:**
|
|
- Create: `src/components/admin/YouTubeAnalyticsDashboardView.tsx`
|
|
- Modify: `src/payload.config.ts:128-141`
|
|
|
|
**Step 1: Create wrapper component**
|
|
|
|
```typescript
|
|
// src/components/admin/YouTubeAnalyticsDashboardView.tsx
|
|
'use client'
|
|
|
|
import React from 'react'
|
|
import { YouTubeAnalyticsDashboard } from './YouTubeAnalyticsDashboard'
|
|
|
|
export const YouTubeAnalyticsDashboardView: React.FC = () => {
|
|
return <YouTubeAnalyticsDashboard />
|
|
}
|
|
|
|
export default YouTubeAnalyticsDashboardView
|
|
```
|
|
|
|
Note: The `YouTubeAnalyticsDashboard` component is created in Task 5. This wrapper will fail to compile until Task 5 is done — that's expected.
|
|
|
|
**Step 2: Register in payload.config.ts**
|
|
|
|
Change lines 130-140 from:
|
|
|
|
```typescript
|
|
components: {
|
|
// Community Management Nav Links
|
|
afterNavLinks: ['@/components/admin/CommunityNavLinks#CommunityNavLinks'],
|
|
// Custom Views - re-enabled after Payload 3.76.1 upgrade (Issue #15241 test)
|
|
views: {
|
|
TenantDashboard: {
|
|
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
|
|
path: '/tenant-dashboard',
|
|
},
|
|
},
|
|
},
|
|
```
|
|
|
|
To:
|
|
|
|
```typescript
|
|
components: {
|
|
afterNavLinks: [
|
|
'@/components/admin/CommunityNavLinks#CommunityNavLinks',
|
|
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
|
|
],
|
|
views: {
|
|
TenantDashboard: {
|
|
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
|
|
path: '/tenant-dashboard',
|
|
},
|
|
YouTubeAnalyticsDashboard: {
|
|
Component: '@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView',
|
|
path: '/youtube-analytics',
|
|
},
|
|
},
|
|
},
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/admin/YouTubeAnalyticsDashboardView.tsx src/payload.config.ts
|
|
git commit -m "feat(yt-analytics): register custom view and nav link in config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Main Dashboard Component — Shell with Tabs
|
|
|
|
**Files:**
|
|
- Create: `src/components/admin/YouTubeAnalyticsDashboard.tsx`
|
|
- Create: `src/components/admin/YouTubeAnalyticsDashboard.scss`
|
|
|
|
**Step 1: Create main component with tab shell, channel/period selectors, loading/error states**
|
|
|
|
The component structure:
|
|
- Header with title, channel selector, period selector, refresh button
|
|
- Tab bar (Performance | Pipeline | Goals | Community)
|
|
- Tab content area (renders based on `activeTab` state)
|
|
- Each tab is a separate render function within the component
|
|
- Data fetched via `useEffect` when tab/channel/period changes
|
|
|
|
The component should follow the TenantDashboard pattern exactly:
|
|
- `'use client'` directive
|
|
- `useTenantSelection()` for tenant context
|
|
- `useState` for: `activeTab`, `data`, `loading`, `error`, `channel`, `period`, `channels`
|
|
- `useCallback` for `fetchData`
|
|
- `useEffect` to trigger `fetchData` on tab/channel/period change
|
|
- `credentials: 'include'` on fetch
|
|
- Loading spinner, error display, no-tenant fallback
|
|
|
|
Key types to define:
|
|
|
|
```typescript
|
|
type Tab = 'performance' | 'pipeline' | 'goals' | 'community'
|
|
type Period = '7d' | '30d' | '90d'
|
|
|
|
interface Channel { id: number; name: string; slug: string }
|
|
|
|
// Performance tab types
|
|
interface PerformanceData {
|
|
stats: { totalViews: number; totalWatchTimeHours: number; avgCtr: number; subscribersGained: number }
|
|
comparison: { views: number; watchTime: number; subscribers: number }
|
|
topVideos: VideoSummary[]
|
|
bottomVideos: VideoSummary[]
|
|
engagement: { likes: number; comments: number; shares: number }
|
|
}
|
|
interface VideoSummary { id: number; title: string; thumbnailText: string; views: number; ctr: number; avgRetention: number; likes: number }
|
|
|
|
// Pipeline tab types
|
|
interface PipelineData {
|
|
stats: { inProduction: number; thisWeekCount: number; overdueTasksCount: number; pendingApprovals: number }
|
|
pipeline: Record<string, number>
|
|
thisWeekVideos: { id: number; title: string; status: string; scheduledPublishDate: string; channel: string }[]
|
|
overdueTasks: { id: number; title: string; dueDate: string; assignedTo: string; video: string | null }[]
|
|
}
|
|
|
|
// Goals tab types
|
|
interface GoalsData {
|
|
stats: { contentProgress: number; contentLabel: string; subscriberProgress: number; subscriberLabel: string; viewsProgress: number; viewsLabel: string; overall: number }
|
|
goals: any[]
|
|
customGoals: { metric: string; target: string; current: string; status: string }[]
|
|
}
|
|
|
|
// Community tab types
|
|
interface CommunityData {
|
|
stats: { unresolvedCount: number; positivePct: number; avgResponseHours: number; escalationsCount: number }
|
|
sentiment: { positive: number; neutral: number; negative: number; question: number }
|
|
topTopics: { topic: string; count: number }[]
|
|
recentUnresolved: { id: number; message: string; authorName: string; authorHandle: string; publishedAt: string; platform: string; sentiment: string }[]
|
|
}
|
|
```
|
|
|
|
Tab rendering sections:
|
|
|
|
**Performance tab:**
|
|
- 4 stat cards (Views, Watch Time, CTR, Subscribers) — each with comparison arrow
|
|
- "Top 5 Videos" section with table rows
|
|
- "Bottom 5 Videos" section
|
|
- "Engagement" section with 3 inline stats
|
|
|
|
**Pipeline tab:**
|
|
- 4 stat cards (In Production, This Week, Overdue Tasks, Pending Approvals)
|
|
- Pipeline bar: horizontal segmented bar showing count per status
|
|
- "Nächste Veröffentlichungen" list
|
|
- "Überfällige Aufgaben" list
|
|
|
|
**Goals tab:**
|
|
- 4 stat cards (Content %, Subscribers %, Views %, Overall %)
|
|
- Progress bars for each goal category
|
|
- Custom goals list with status badges
|
|
|
|
**Community tab:**
|
|
- 4 stat cards (Unresolved, Positive %, Avg Response Time, Escalations)
|
|
- Sentiment breakdown (colored bars)
|
|
- Recent unresolved list
|
|
- Top topics list
|
|
|
|
**Step 2: Create SCSS**
|
|
|
|
BEM structure with `.yt-analytics` prefix. Key classes:
|
|
|
|
```
|
|
.yt-analytics — container
|
|
.yt-analytics__header — title row
|
|
.yt-analytics__controls — channel + period selectors + refresh
|
|
.yt-analytics__tabs — tab bar
|
|
.yt-analytics__tab — single tab
|
|
.yt-analytics__tab--active — active tab
|
|
.yt-analytics__content — tab content area
|
|
.yt-analytics__stats-grid — 4-card grid (reuse TenantDashboard pattern)
|
|
.yt-analytics__stat-card — single stat card
|
|
.yt-analytics__stat-card--up — green trend arrow
|
|
.yt-analytics__stat-card--down — red trend arrow
|
|
.yt-analytics__section — content section
|
|
.yt-analytics__pipeline-bar — horizontal pipeline visualization
|
|
.yt-analytics__pipeline-segment — single pipeline segment
|
|
.yt-analytics__progress-bar — goals progress bar
|
|
.yt-analytics__progress-fill — progress bar fill
|
|
.yt-analytics__video-row — video list item
|
|
.yt-analytics__topic-tag — topic pill
|
|
.yt-analytics__comment-item — community comment preview
|
|
```
|
|
|
|
Colors for pipeline segments (CSS custom properties):
|
|
- idea: `--theme-elevation-300`
|
|
- script: `#6366f1` (indigo)
|
|
- review: `#f59e0b` (amber)
|
|
- production: `#3b82f6` (blue)
|
|
- editing: `#8b5cf6` (violet)
|
|
- ready: `#10b981` (emerald)
|
|
- published: `var(--theme-success-500)`
|
|
|
|
Responsive: `@media (max-width: 768px)` — 2-col stat grid, stacked layout
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/admin/YouTubeAnalyticsDashboard.tsx src/components/admin/YouTubeAnalyticsDashboard.scss
|
|
git commit -m "feat(yt-analytics): add main dashboard component with all 4 tabs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Build, Generate ImportMap, Deploy to Staging
|
|
|
|
**Files:**
|
|
- Modify: `src/app/(payload)/admin/importMap.js` (auto-generated)
|
|
|
|
**Step 1: Generate importMap**
|
|
|
|
```bash
|
|
pnpm payload generate:importmap
|
|
```
|
|
|
|
Expected: importMap.js updated with new component paths
|
|
|
|
**Step 2: Build**
|
|
|
|
```bash
|
|
pm2 stop payload && NODE_OPTIONS="--no-deprecation --max-old-space-size=2048" pnpm build
|
|
```
|
|
|
|
Expected: Build succeeds without errors
|
|
|
|
**Step 3: Start and verify**
|
|
|
|
```bash
|
|
pm2 start payload
|
|
```
|
|
|
|
Test endpoints:
|
|
- `curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/admin/youtube-analytics` → 200
|
|
- Login via browser and navigate to `/admin/youtube-analytics`
|
|
- Verify nav link appears in sidebar
|
|
- Verify all 4 tabs load data
|
|
|
|
**Step 4: Commit build artifacts**
|
|
|
|
```bash
|
|
git add src/app/\(payload\)/admin/importMap.js
|
|
git commit -m "chore: regenerate importMap for YouTube Analytics Dashboard"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Final Commit + Push
|
|
|
|
**Step 1: Push to develop**
|
|
|
|
```bash
|
|
git push origin develop
|
|
```
|
|
|
|
**Step 2: Verify on staging**
|
|
|
|
Browser test:
|
|
1. Navigate to `https://pl.porwoll.tech/admin`
|
|
2. Login
|
|
3. Check sidebar for "YouTube" section with "YouTube Analytics" link
|
|
4. Click link → Dashboard loads
|
|
5. Switch tabs: Performance, Pipeline, Goals, Community
|
|
6. Change channel selector
|
|
7. Change period selector
|
|
8. Click refresh button
|