cms.c2sgmbh/prompts/phase1_completion-extended.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows)
- Add prompts/ directory with development planning documents
- Add scripts/setup-tenants/ with tenant configuration
- Add docs/screenshots/
- Remove obsolete phase2.2-corrections-report.md
- Update pnpm-lock.yaml
- Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 10:18:05 +00:00

4169 lines
119 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 1 Completion Integration Prompt (Extended)
## Übersicht
Dieser Prompt vervollständigt Phase 1 des Community Management Systems mit:
1. **Community Inbox View** Custom Admin View für tägliches Community Management
2. **Mobile-Optimierte Inbox** Vollständig responsive für Smartphone-Nutzung
3. **YouTube OAuth Flow** Authentifizierung + Token Management
4. **Auto-Sync Cron Job** Automatischer Kommentar-Import
5. **Rules Engine** Automatische Regel-Ausführung bei neuen Interaktionen
6. **Export-Funktion** PDF/Excel Reports für Analysen
**Geschätzter Aufwand:** 7-9 Tage
---
## Teil 1: Community Inbox View (Mobile-Optimiert)
### 1.1 View Registration
```typescript
// src/app/(payload)/admin/views/community/inbox/page.tsx
import React from 'react'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { Gutter } from '@payloadcms/ui'
import { CommunityInbox } from './CommunityInbox'
import './inbox.scss'
export const metadata = {
title: 'Community Inbox',
description: 'Manage community interactions across all platforms',
}
export default function CommunityInboxPage() {
return (
<DefaultTemplate>
<Gutter>
<CommunityInbox />
</Gutter>
</DefaultTemplate>
)
}
```
### 1.2 Main Component (mit Mobile Support)
```tsx
// src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useConfig } from '@payloadcms/ui'
interface Interaction {
id: number
platform: { name: string; icon: string; slug: string }
socialAccount: { displayName: string }
linkedContent?: { title: string; youtubeVideoId: string }
type: string
externalId: string
author: {
name: string
handle: string
avatarUrl: string
isVerified: boolean
isSubscriber: boolean
subscriberCount: number
}
message: string
publishedAt: string
analysis: {
sentiment: string
sentimentScore: number
confidence: number
topics: { topic: string }[]
suggestedReply: string
}
flags: {
isMedicalQuestion: boolean
requiresEscalation: boolean
isSpam: boolean
isFromInfluencer: boolean
}
status: string
priority: string
assignedTo?: { id: number; email: string; name?: string }
response?: {
text: string
sentAt: string
sentBy: { email: string }
}
engagement: {
likes: number
replies: number
isHearted: boolean
isPinned: boolean
}
createdAt: string
updatedAt: string
}
interface Filters {
status: string[]
priority: string[]
platform: string[]
channel: string[]
sentiment: string[]
flags: string[]
assignedTo: string
search: string
dateFrom: string
dateTo: string
}
const DEFAULT_FILTERS: Filters = {
status: ['new', 'in_review', 'waiting'],
priority: [],
platform: [],
channel: [],
sentiment: [],
flags: [],
assignedTo: '',
search: '',
dateFrom: '',
dateTo: '',
}
export const CommunityInbox: React.FC = () => {
const { config } = useConfig()
// State
const [interactions, setInteractions] = useState<Interaction[]>([])
const [selectedInteraction, setSelectedInteraction] = useState<Interaction | null>(null)
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [replyText, setReplyText] = useState('')
const [sendingReply, setSendingReply] = useState(false)
const [exporting, setExporting] = useState(false)
// Mobile state
const [isMobile, setIsMobile] = useState(false)
const [mobileView, setMobileView] = useState<'list' | 'detail' | 'filters'>('list')
const [showMobileFilters, setShowMobileFilters] = useState(false)
// Export state
const [showExportModal, setShowExportModal] = useState(false)
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel')
const [exportDateRange, setExportDateRange] = useState({ from: '', to: '' })
// Refs
const listRef = useRef<HTMLUListElement>(null)
const detailRef = useRef<HTMLDivElement>(null)
// Metadata for filters
const [platforms, setPlatforms] = useState<any[]>([])
const [channels, setChannels] = useState<any[]>([])
const [templates, setTemplates] = useState<any[]>([])
const [users, setUsers] = useState<any[]>([])
// Stats
const [stats, setStats] = useState({
total: 0,
new: 0,
urgent: 0,
medical: 0,
unassigned: 0,
})
// Detect mobile
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Load metadata
useEffect(() => {
const loadMetadata = async () => {
try {
const [platformsRes, channelsRes, templatesRes, usersRes] = await Promise.all([
fetch('/api/social-platforms?limit=100'),
fetch('/api/youtube-channels?limit=100'),
fetch('/api/community-templates?where[isActive][equals]=true&limit=100'),
fetch('/api/users?where[communityRole][not_equals]=none&limit=100'),
])
const [platformsData, channelsData, templatesData, usersData] = await Promise.all([
platformsRes.json(),
channelsRes.json(),
templatesRes.json(),
usersRes.json(),
])
setPlatforms(platformsData.docs || [])
setChannels(channelsData.docs || [])
setTemplates(templatesData.docs || [])
setUsers(usersData.docs || [])
} catch (error) {
console.error('Failed to load metadata:', error)
}
}
loadMetadata()
}, [])
// Load interactions
const loadInteractions = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('limit', '50')
params.set('sort', '-createdAt')
params.set('depth', '2')
// Apply filters
if (filters.status.length > 0) {
params.set('where[status][in]', filters.status.join(','))
}
if (filters.priority.length > 0) {
params.set('where[priority][in]', filters.priority.join(','))
}
if (filters.platform.length > 0) {
params.set('where[platform][in]', filters.platform.join(','))
}
if (filters.sentiment.length > 0) {
params.set('where[analysis.sentiment][in]', filters.sentiment.join(','))
}
if (filters.flags.includes('medical')) {
params.set('where[flags.isMedicalQuestion][equals]', 'true')
}
if (filters.flags.includes('escalation')) {
params.set('where[flags.requiresEscalation][equals]', 'true')
}
if (filters.flags.includes('influencer')) {
params.set('where[flags.isFromInfluencer][equals]', 'true')
}
if (filters.assignedTo) {
if (filters.assignedTo === 'unassigned') {
params.set('where[assignedTo][exists]', 'false')
} else {
params.set('where[assignedTo][equals]', filters.assignedTo)
}
}
if (filters.search) {
params.set('where[or][0][message][contains]', filters.search)
params.set('where[or][1][author.name][contains]', filters.search)
}
if (filters.dateFrom) {
params.set('where[publishedAt][greater_than_equal]', filters.dateFrom)
}
if (filters.dateTo) {
params.set('where[publishedAt][less_than_equal]', filters.dateTo)
}
const response = await fetch(`/api/community-interactions?${params}`)
const data = await response.json()
setInteractions(data.docs || [])
// Update stats
const statsResponse = await fetch('/api/community-interactions/stats')
if (statsResponse.ok) {
const statsData = await statsResponse.json()
setStats(statsData)
}
} catch (error) {
console.error('Failed to load interactions:', error)
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => {
loadInteractions()
}, [loadInteractions])
// Sync comments
const handleSync = async () => {
setSyncing(true)
try {
const response = await fetch('/api/community/sync-comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analyzeWithAI: true }),
})
if (response.ok) {
const result = await response.json()
if (isMobile) {
alert(`✅ ${result.created} neue, ${result.updated} aktualisiert`)
} else {
alert(`Sync complete: ${result.created} new, ${result.updated} updated`)
}
loadInteractions()
} else {
throw new Error('Sync failed')
}
} catch (error) {
console.error('Sync error:', error)
alert('Sync fehlgeschlagen. Details in der Console.')
} finally {
setSyncing(false)
}
}
// Export function
const handleExport = async () => {
setExporting(true)
try {
const params = new URLSearchParams()
params.set('format', exportFormat)
if (exportDateRange.from) {
params.set('dateFrom', exportDateRange.from)
}
if (exportDateRange.to) {
params.set('dateTo', exportDateRange.to)
}
// Include current filters
if (filters.status.length > 0) {
params.set('status', filters.status.join(','))
}
if (filters.platform.length > 0) {
params.set('platform', filters.platform.join(','))
}
if (filters.sentiment.length > 0) {
params.set('sentiment', filters.sentiment.join(','))
}
const response = await fetch(`/api/community/export?${params}`)
if (!response.ok) {
throw new Error('Export failed')
}
// Download file
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const extension = exportFormat === 'excel' ? 'xlsx' : exportFormat
const timestamp = new Date().toISOString().split('T')[0]
a.download = `community-report-${timestamp}.${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
setShowExportModal(false)
} catch (error) {
console.error('Export error:', error)
alert('Export fehlgeschlagen')
} finally {
setExporting(false)
}
}
// Update interaction status
const updateStatus = async (id: number, status: string) => {
try {
await fetch(`/api/community-interactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
setInteractions(prev =>
prev.map(i => i.id === id ? { ...i, status } : i)
)
if (selectedInteraction?.id === id) {
setSelectedInteraction(prev => prev ? { ...prev, status } : null)
}
} catch (error) {
console.error('Failed to update status:', error)
}
}
// Assign interaction
const assignTo = async (id: number, userId: string) => {
try {
await fetch(`/api/community-interactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignedTo: userId || null }),
})
loadInteractions()
} catch (error) {
console.error('Failed to assign:', error)
}
}
// Send reply
const sendReply = async () => {
if (!selectedInteraction || !replyText.trim()) return
setSendingReply(true)
try {
const response = await fetch('/api/community/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interactionId: selectedInteraction.id,
text: replyText,
}),
})
if (response.ok) {
setReplyText('')
updateStatus(selectedInteraction.id, 'replied')
loadInteractions()
if (isMobile) {
alert('✅ Antwort gesendet!')
}
} else {
throw new Error('Reply failed')
}
} catch (error) {
console.error('Reply error:', error)
alert('Antwort konnte nicht gesendet werden.')
} finally {
setSendingReply(false)
}
}
// Apply template
const applyTemplate = (template: any) => {
let text = template.template
if (selectedInteraction) {
text = text.replace(/\{\{author_name\}\}/g, selectedInteraction.author.name || 'there')
text = text.replace(/\{\{video_title\}\}/g, selectedInteraction.linkedContent?.title || '')
text = text.replace(/\{\{channel_name\}\}/g, selectedInteraction.socialAccount?.displayName || '')
}
setReplyText(text)
}
// Generate AI reply
const generateAIReply = async () => {
if (!selectedInteraction) return
try {
const response = await fetch('/api/community/generate-reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interactionId: selectedInteraction.id,
}),
})
if (response.ok) {
const data = await response.json()
setReplyText(data.reply)
}
} catch (error) {
console.error('AI generation error:', error)
}
}
// Select interaction (mobile-aware)
const selectInteraction = (interaction: Interaction) => {
setSelectedInteraction(interaction)
if (interaction.status === 'new') {
updateStatus(interaction.id, 'in_review')
}
if (isMobile) {
setMobileView('detail')
}
}
// Mobile back navigation
const handleMobileBack = () => {
if (mobileView === 'detail') {
setMobileView('list')
setSelectedInteraction(null)
} else if (mobileView === 'filters') {
setMobileView('list')
}
}
// Render helpers
const getSentimentEmoji = (sentiment: string) => {
switch (sentiment) {
case 'positive': return '😊'
case 'negative': return '😟'
case 'mixed': return '😐'
case 'question': return '❓'
default: return '•'
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return '#dc2626'
case 'high': return '#f97316'
case 'normal': return '#3b82f6'
case 'low': return '#22c55e'
default: return '#6b7280'
}
}
const getStatusBadge = (status: string) => {
// Status-Werte gemäß CommunityInteractions.ts
const badges: Record<string, { label: string; color: string }> = {
new: { label: 'Neu', color: '#3b82f6' },
in_review: { label: 'In Review', color: '#f97316' },
waiting: { label: 'Warten auf Info', color: '#8b5cf6' },
replied: { label: 'Beantwortet', color: '#22c55e' },
resolved: { label: 'Erledigt', color: '#10b981' },
archived: { label: 'Archiviert', color: '#1f2937' },
spam: { label: 'Spam', color: '#dc2626' },
}
return badges[status] || { label: status, color: '#6b7280' }
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 60) return `${diffMins}m`
if (diffHours < 24) return `${diffHours}h`
if (diffDays < 7) return `${diffDays}d`
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
// Active filters count
const activeFiltersCount = [
filters.status.length !== 3 ? 1 : 0, // Default is 3
filters.priority.length > 0 ? 1 : 0,
filters.platform.length > 0 ? 1 : 0,
filters.sentiment.length > 0 ? 1 : 0,
filters.flags.length > 0 ? 1 : 0,
filters.assignedTo ? 1 : 0,
filters.search ? 1 : 0,
].reduce((a, b) => a + b, 0)
// ============================================
// MOBILE RENDER
// ============================================
if (isMobile) {
return (
<div className="community-inbox mobile">
{/* Mobile Header */}
<div className="mobile-header">
{mobileView === 'list' ? (
<>
<h1>📬 Inbox</h1>
<div className="mobile-header-actions">
<button
className="btn-icon"
onClick={() => setMobileView('filters')}
>
🎛️ {activeFiltersCount > 0 && <span className="filter-badge">{activeFiltersCount}</span>}
</button>
<button
className="btn-icon"
onClick={() => setShowExportModal(true)}
>
📊
</button>
<button
className="btn-icon"
onClick={handleSync}
disabled={syncing}
>
{syncing ? '⏳' : '🔄'}
</button>
</div>
</>
) : (
<>
<button className="btn-back" onClick={handleMobileBack}>
Zurück
</button>
<span className="mobile-title">
{mobileView === 'detail' ? 'Details' : 'Filter'}
</span>
{mobileView === 'filters' && (
<button
className="btn-text"
onClick={() => {
setFilters(DEFAULT_FILTERS)
setMobileView('list')
}}
>
Reset
</button>
)}
</>
)}
</div>
{/* Mobile Stats Bar */}
{mobileView === 'list' && (
<div className="mobile-stats">
<span className="stat">{stats.new} <small>neu</small></span>
<span className="stat urgent">{stats.urgent} <small>urgent</small></span>
<span className="stat medical">{stats.medical} <small>med.</small></span>
</div>
)}
{/* Mobile Search */}
{mobileView === 'list' && (
<div className="mobile-search">
<input
type="search"
placeholder="🔍 Suchen..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
/>
</div>
)}
{/* Mobile Filters View */}
{mobileView === 'filters' && (
<div className="mobile-filters">
<div className="filter-section">
<h3>Status</h3>
<div className="filter-chips">
{['new', 'in_review', 'waiting', 'replied', 'resolved', 'archived', 'spam'].map(status => (
<button
key={status}
className={`chip ${filters.status.includes(status) ? 'active' : ''}`}
onClick={() => {
setFilters(prev => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter(s => s !== status)
: [...prev.status, status]
}))
}}
>
{getStatusBadge(status).label}
</button>
))}
</div>
</div>
<div className="filter-section">
<h3>Priorität</h3>
<div className="filter-chips">
{['urgent', 'high', 'normal', 'low'].map(priority => (
<button
key={priority}
className={`chip ${filters.priority.includes(priority) ? 'active' : ''}`}
style={{
borderColor: filters.priority.includes(priority) ? getPriorityColor(priority) : undefined,
backgroundColor: filters.priority.includes(priority) ? getPriorityColor(priority) + '20' : undefined,
}}
onClick={() => {
setFilters(prev => ({
...prev,
priority: prev.priority.includes(priority)
? prev.priority.filter(p => p !== priority)
: [...prev.priority, priority]
}))
}}
>
{priority}
</button>
))}
</div>
</div>
<div className="filter-section">
<h3>Sentiment</h3>
<div className="filter-chips">
{['positive', 'neutral', 'negative', 'mixed'].map(sentiment => (
<button
key={sentiment}
className={`chip ${filters.sentiment.includes(sentiment) ? 'active' : ''}`}
onClick={() => {
setFilters(prev => ({
...prev,
sentiment: prev.sentiment.includes(sentiment)
? prev.sentiment.filter(s => s !== sentiment)
: [...prev.sentiment, sentiment]
}))
}}
>
{getSentimentEmoji(sentiment)} {sentiment}
</button>
))}
</div>
</div>
<div className="filter-section">
<h3>Flags</h3>
<div className="filter-chips">
<button
className={`chip ${filters.flags.includes('medical') ? 'active' : ''}`}
onClick={() => {
setFilters(prev => ({
...prev,
flags: prev.flags.includes('medical')
? prev.flags.filter(f => f !== 'medical')
: [...prev.flags, 'medical']
}))
}}
>
⚕️ Medical
</button>
<button
className={`chip ${filters.flags.includes('escalation') ? 'active' : ''}`}
onClick={() => {
setFilters(prev => ({
...prev,
flags: prev.flags.includes('escalation')
? prev.flags.filter(f => f !== 'escalation')
: [...prev.flags, 'escalation']
}))
}}
>
🚨 Eskalation
</button>
<button
className={`chip ${filters.flags.includes('influencer') ? 'active' : ''}`}
onClick={() => {
setFilters(prev => ({
...prev,
flags: prev.flags.includes('influencer')
? prev.flags.filter(f => f !== 'influencer')
: [...prev.flags, 'influencer']
}))
}}
>
Influencer
</button>
</div>
</div>
<div className="filter-section">
<h3>Zugewiesen an</h3>
<select
value={filters.assignedTo}
onChange={(e) => setFilters(prev => ({ ...prev, assignedTo: e.target.value }))}
className="mobile-select"
>
<option value="">Alle</option>
<option value="unassigned">Nicht zugewiesen</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name || user.email}
</option>
))}
</select>
</div>
<button
className="btn btn-primary btn-full"
onClick={() => setMobileView('list')}
>
Filter anwenden ({interactions.length} Ergebnisse)
</button>
</div>
)}
{/* Mobile List View */}
{mobileView === 'list' && (
<div className="mobile-list">
{loading ? (
<div className="loading">
<div className="spinner" />
</div>
) : interactions.length === 0 ? (
<div className="empty-state">
<p>Keine Interaktionen</p>
<button onClick={() => setFilters(DEFAULT_FILTERS)}>
Filter zurücksetzen
</button>
</div>
) : (
<ul>
{interactions.map(interaction => (
<li
key={interaction.id}
className={`mobile-item ${interaction.status === 'new' ? 'unread' : ''}`}
onClick={() => selectInteraction(interaction)}
>
<div className="item-row">
<span
className="priority-bar"
style={{ backgroundColor: getPriorityColor(interaction.priority) }}
/>
<div className="item-content">
<div className="item-header">
<span className="author">
{interaction.platform?.icon} {interaction.author?.name}
</span>
<span className="time">{formatDate(interaction.publishedAt)}</span>
</div>
<p className="message">
{interaction.message?.substring(0, 80)}...
</p>
<div className="item-badges">
{interaction.analysis?.sentiment && (
<span className="badge">{getSentimentEmoji(interaction.analysis.sentiment)}</span>
)}
{interaction.flags?.isMedicalQuestion && (
<span className="badge medical">⚕️</span>
)}
{interaction.flags?.requiresEscalation && (
<span className="badge urgent">🚨</span>
)}
{interaction.flags?.isFromInfluencer && (
<span className="badge"></span>
)}
<span
className="badge status"
style={{ backgroundColor: getStatusBadge(interaction.status).color }}
>
{getStatusBadge(interaction.status).label}
</span>
</div>
</div>
<span className="chevron"></span>
</div>
</li>
))}
</ul>
)}
</div>
)}
{/* Mobile Detail View */}
{mobileView === 'detail' && selectedInteraction && (
<div className="mobile-detail">
{/* Author Card */}
<div className="author-card">
{selectedInteraction.author?.avatarUrl && (
<img
src={selectedInteraction.author.avatarUrl}
alt=""
className="avatar"
/>
)}
<div className="author-info">
<h3>
{selectedInteraction.author?.name}
{selectedInteraction.author?.isVerified && ' ✓'}
</h3>
<p>
{selectedInteraction.author?.handle}
{selectedInteraction.author?.subscriberCount > 0 && (
` • ${selectedInteraction.author.subscriberCount.toLocaleString()} Follower`
)}
</p>
</div>
</div>
{/* Quick Actions */}
<div className="quick-actions">
<select
value={selectedInteraction.status}
onChange={(e) => updateStatus(selectedInteraction.id, e.target.value)}
>
<option value="new">🆕 Neu</option>
<option value="in_review">👁️ In Review</option>
<option value="waiting"> Warten auf Info</option>
<option value="replied"> Beantwortet</option>
<option value="resolved">✔️ Erledigt</option>
<option value="archived">📦 Archiviert</option>
<option value="spam">🚫 Spam</option>
</select>
<select
value={selectedInteraction.assignedTo?.id || ''}
onChange={(e) => assignTo(selectedInteraction.id, e.target.value)}
>
<option value="">Nicht zugewiesen</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name || user.email}
</option>
))}
</select>
</div>
{/* Flags Alert */}
{(selectedInteraction.flags?.isMedicalQuestion ||
selectedInteraction.flags?.requiresEscalation) && (
<div className="mobile-alerts">
{selectedInteraction.flags.isMedicalQuestion && (
<div className="alert medical">
⚕️ Medizinische Frage Auf Hotline verweisen!
</div>
)}
{selectedInteraction.flags.requiresEscalation && (
<div className="alert urgent">
🚨 Eskalation erforderlich
</div>
)}
</div>
)}
{/* Context */}
{selectedInteraction.linkedContent && (
<a
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
className="context-link"
>
📺 {selectedInteraction.linkedContent.title}
</a>
)}
{/* Message */}
<div className="message-card">
<p>{selectedInteraction.message}</p>
<div className="message-meta">
<span>{formatDate(selectedInteraction.publishedAt)}</span>
<span>❤️ {selectedInteraction.engagement?.likes || 0}</span>
<span>💬 {selectedInteraction.engagement?.replies || 0}</span>
</div>
</div>
{/* AI Analysis */}
{selectedInteraction.analysis && (
<div className="analysis-card">
<h4>🤖 KI-Analyse</h4>
<div className="analysis-row">
<span>Sentiment:</span>
<span>
{getSentimentEmoji(selectedInteraction.analysis.sentiment)}
{' '}{selectedInteraction.analysis.sentiment}
</span>
</div>
{selectedInteraction.analysis.topics?.length > 0 && (
<div className="analysis-row">
<span>Themen:</span>
<div className="topics">
{selectedInteraction.analysis.topics.map((t, i) => (
<span key={i} className="topic">{t.topic}</span>
))}
</div>
</div>
)}
</div>
)}
{/* Previous Response */}
{selectedInteraction.response?.text && (
<div className="response-card">
<h4> Unsere Antwort</h4>
<p>{selectedInteraction.response.text}</p>
<small>
{formatDate(selectedInteraction.response.sentAt)}
</small>
</div>
)}
{/* Reply Section */}
{!['resolved', 'archived', 'spam'].includes(selectedInteraction.status) && !selectedInteraction.response?.text && (
<div className="reply-section">
<div className="reply-header">
<select
onChange={(e) => {
const template = templates.find(t => t.id === Number(e.target.value))
if (template) applyTemplate(template)
}}
defaultValue=""
>
<option value="">📝 Template...</option>
{templates.map(template => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
<button
className="btn-ai"
onClick={generateAIReply}
>
🤖 KI
</button>
</div>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Antwort schreiben..."
rows={4}
/>
<button
className="btn btn-primary btn-full"
onClick={sendReply}
disabled={!replyText.trim() || sendingReply}
>
{sendingReply ? '⏳ Sende...' : '📤 Antworten'}
</button>
</div>
)}
</div>
)}
{/* Export Modal */}
{showExportModal && (
<div className="mobile-modal-overlay" onClick={() => setShowExportModal(false)}>
<div className="mobile-modal" onClick={(e) => e.stopPropagation()}>
<h3>📊 Report exportieren</h3>
<div className="modal-field">
<label>Format</label>
<div className="format-buttons">
<button
className={exportFormat === 'excel' ? 'active' : ''}
onClick={() => setExportFormat('excel')}
>
📗 Excel
</button>
<button
className={exportFormat === 'csv' ? 'active' : ''}
onClick={() => setExportFormat('csv')}
>
📄 CSV
</button>
<button
className={exportFormat === 'pdf' ? 'active' : ''}
onClick={() => setExportFormat('pdf')}
>
📕 PDF
</button>
</div>
</div>
<div className="modal-field">
<label>Zeitraum</label>
<input
type="date"
value={exportDateRange.from}
onChange={(e) => setExportDateRange(prev => ({ ...prev, from: e.target.value }))}
placeholder="Von"
/>
<input
type="date"
value={exportDateRange.to}
onChange={(e) => setExportDateRange(prev => ({ ...prev, to: e.target.value }))}
placeholder="Bis"
/>
</div>
<div className="modal-actions">
<button onClick={() => setShowExportModal(false)}>
Abbrechen
</button>
<button
className="btn-primary"
onClick={handleExport}
disabled={exporting}
>
{exporting ? '⏳' : '📥'} Exportieren
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ============================================
// DESKTOP RENDER
// ============================================
return (
<div className="community-inbox desktop">
{/* Header */}
<div className="inbox-header">
<div className="inbox-title">
<h1>📬 Community Inbox</h1>
<div className="inbox-stats">
<span className="stat">
<strong>{stats.total}</strong> Gesamt
</span>
<span className="stat stat-new">
<strong>{stats.new}</strong> Neu
</span>
<span className="stat stat-urgent">
<strong>{stats.urgent}</strong> Urgent
</span>
<span className="stat stat-medical">
<strong>{stats.medical}</strong> Medical
</span>
</div>
</div>
<div className="inbox-actions">
<button
className="btn btn-export"
onClick={() => setShowExportModal(true)}
>
📊 Export
</button>
<button
className="btn btn-sync"
onClick={handleSync}
disabled={syncing}
>
{syncing ? '🔄 Syncing...' : '🔄 Sync Comments'}
</button>
</div>
</div>
<div className="inbox-layout">
{/* Sidebar Filters */}
<aside className="inbox-sidebar">
<div className="filter-section">
<h3>Status</h3>
<div className="filter-options">
{['new', 'in_review', 'waiting', 'replied', 'resolved', 'archived', 'spam'].map(status => (
<label key={status} className="filter-checkbox">
<input
type="checkbox"
checked={filters.status.includes(status)}
onChange={(e) => {
setFilters(prev => ({
...prev,
status: e.target.checked
? [...prev.status, status]
: prev.status.filter(s => s !== status)
}))
}}
/>
<span
className="status-dot"
style={{ backgroundColor: getStatusBadge(status).color }}
/>
{getStatusBadge(status).label}
</label>
))}
</div>
</div>
<div className="filter-section">
<h3>Priorität</h3>
<div className="filter-options">
{['urgent', 'high', 'normal', 'low'].map(priority => (
<label key={priority} className="filter-checkbox">
<input
type="checkbox"
checked={filters.priority.includes(priority)}
onChange={(e) => {
setFilters(prev => ({
...prev,
priority: e.target.checked
? [...prev.priority, priority]
: prev.priority.filter(p => p !== priority)
}))
}}
/>
<span
className="priority-dot"
style={{ backgroundColor: getPriorityColor(priority) }}
/>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</label>
))}
</div>
</div>
<div className="filter-section">
<h3>Plattform</h3>
<div className="filter-options">
{platforms.map(platform => (
<label key={platform.id} className="filter-checkbox">
<input
type="checkbox"
checked={filters.platform.includes(String(platform.id))}
onChange={(e) => {
setFilters(prev => ({
...prev,
platform: e.target.checked
? [...prev.platform, String(platform.id)]
: prev.platform.filter(p => p !== String(platform.id))
}))
}}
/>
{platform.icon} {platform.name}
</label>
))}
</div>
</div>
<div className="filter-section">
<h3>Sentiment</h3>
<div className="filter-options">
{['positive', 'neutral', 'negative', 'mixed'].map(sentiment => (
<label key={sentiment} className="filter-checkbox">
<input
type="checkbox"
checked={filters.sentiment.includes(sentiment)}
onChange={(e) => {
setFilters(prev => ({
...prev,
sentiment: e.target.checked
? [...prev.sentiment, sentiment]
: prev.sentiment.filter(s => s !== sentiment)
}))
}}
/>
{getSentimentEmoji(sentiment)} {sentiment}
</label>
))}
</div>
</div>
<div className="filter-section">
<h3>Flags</h3>
<div className="filter-options">
<label className="filter-checkbox">
<input
type="checkbox"
checked={filters.flags.includes('medical')}
onChange={(e) => {
setFilters(prev => ({
...prev,
flags: e.target.checked
? [...prev.flags, 'medical']
: prev.flags.filter(f => f !== 'medical')
}))
}}
/>
⚕️ Medizinische Frage
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={filters.flags.includes('escalation')}
onChange={(e) => {
setFilters(prev => ({
...prev,
flags: e.target.checked
? [...prev.flags, 'escalation']
: prev.flags.filter(f => f !== 'escalation')
}))
}}
/>
🚨 Eskalation
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={filters.flags.includes('influencer')}
onChange={(e) => {
setFilters(prev => ({
...prev,
flags: e.target.checked
? [...prev.flags, 'influencer']
: prev.flags.filter(f => f !== 'influencer')
}))
}}
/>
Influencer
</label>
</div>
</div>
<div className="filter-section">
<h3>Zugewiesen an</h3>
<select
value={filters.assignedTo}
onChange={(e) => setFilters(prev => ({ ...prev, assignedTo: e.target.value }))}
className="filter-select"
>
<option value="">Alle</option>
<option value="unassigned">Nicht zugewiesen</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name || user.email}
</option>
))}
</select>
</div>
<div className="filter-section">
<h3>Zeitraum</h3>
<div className="date-range">
<input
type="date"
value={filters.dateFrom}
onChange={(e) => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
placeholder="Von"
/>
<input
type="date"
value={filters.dateTo}
onChange={(e) => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
placeholder="Bis"
/>
</div>
</div>
<div className="filter-section">
<h3>Suche</h3>
<input
type="text"
placeholder="Nachricht oder Autor..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="filter-search"
/>
</div>
<button
className="btn btn-reset"
onClick={() => setFilters(DEFAULT_FILTERS)}
>
Filter zurücksetzen
</button>
</aside>
{/* Interaction List */}
<div className="inbox-list">
{loading ? (
<div className="loading">
<div className="spinner" />
<p>Lade Interaktionen...</p>
</div>
) : interactions.length === 0 ? (
<div className="empty-state">
<p>Keine Interaktionen gefunden</p>
<button onClick={() => setFilters(DEFAULT_FILTERS)}>
Filter zurücksetzen
</button>
</div>
) : (
<ul className="interaction-list" ref={listRef}>
{interactions.map(interaction => (
<li
key={interaction.id}
className={`interaction-item ${selectedInteraction?.id === interaction.id ? 'selected' : ''} ${interaction.status === 'new' ? 'unread' : ''}`}
onClick={() => selectInteraction(interaction)}
>
<div className="interaction-header">
<div className="interaction-meta">
<span
className="priority-indicator"
style={{ backgroundColor: getPriorityColor(interaction.priority) }}
title={interaction.priority}
/>
<span className="platform-icon" title={interaction.platform?.name}>
{interaction.platform?.icon || '📱'}
</span>
<span className="author-name">
{interaction.author?.name || 'Unknown'}
{interaction.author?.isVerified && ' ✓'}
</span>
{interaction.flags?.isFromInfluencer && (
<span className="badge badge-influencer"></span>
)}
</div>
<span className="interaction-time">
{formatDate(interaction.publishedAt)}
</span>
</div>
<div className="interaction-content">
<p className="message-preview">
{interaction.message?.substring(0, 120)}
{interaction.message?.length > 120 && '...'}
</p>
</div>
<div className="interaction-footer">
<div className="interaction-badges">
{interaction.analysis?.sentiment && (
<span className="badge badge-sentiment">
{getSentimentEmoji(interaction.analysis.sentiment)}
</span>
)}
{interaction.flags?.isMedicalQuestion && (
<span className="badge badge-medical">⚕️</span>
)}
{interaction.flags?.requiresEscalation && (
<span className="badge badge-escalation">🚨</span>
)}
<span
className="badge badge-status"
style={{ backgroundColor: getStatusBadge(interaction.status).color }}
>
{getStatusBadge(interaction.status).label}
</span>
</div>
{interaction.linkedContent && (
<span className="video-reference">
📺 {interaction.linkedContent.title?.substring(0, 30)}...
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Detail Panel */}
<div className="inbox-detail" ref={detailRef}>
{selectedInteraction ? (
<>
<div className="detail-header">
<div className="author-info">
{selectedInteraction.author?.avatarUrl && (
<img
src={selectedInteraction.author.avatarUrl}
alt={selectedInteraction.author.name}
className="author-avatar"
/>
)}
<div className="author-details">
<h3>
{selectedInteraction.author?.name}
{selectedInteraction.author?.isVerified && (
<span className="verified-badge"></span>
)}
</h3>
<p className="author-handle">
{selectedInteraction.author?.handle}
{selectedInteraction.author?.isSubscriber && ' • Subscriber'}
{selectedInteraction.author?.subscriberCount > 0 && (
` • ${selectedInteraction.author.subscriberCount.toLocaleString()} Follower`
)}
</p>
</div>
</div>
<div className="detail-actions">
<select
value={selectedInteraction.status}
onChange={(e) => updateStatus(selectedInteraction.id, e.target.value)}
className="status-select"
>
<option value="new">🆕 Neu</option>
<option value="read">👁️ Gelesen</option>
<option value="in_progress"> In Arbeit</option>
<option value="awaiting">⏸️ Wartend</option>
<option value="responded"> Beantwortet</option>
<option value="closed">📦 Geschlossen</option>
</select>
<select
value={selectedInteraction.assignedTo?.id || ''}
onChange={(e) => assignTo(selectedInteraction.id, e.target.value)}
className="assign-select"
>
<option value="">Nicht zugewiesen</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name || user.email}
</option>
))}
</select>
</div>
</div>
{/* Flags Alert */}
{(selectedInteraction.flags?.isMedicalQuestion ||
selectedInteraction.flags?.requiresEscalation) && (
<div className="flags-alert">
{selectedInteraction.flags.isMedicalQuestion && (
<div className="alert alert-medical">
⚕️ <strong>Medizinische Frage</strong> Keine medizinischen Ratschläge geben, auf Hotline verweisen!
</div>
)}
{selectedInteraction.flags.requiresEscalation && (
<div className="alert alert-escalation">
🚨 <strong>Eskalation erforderlich</strong> Priorisierte Bearbeitung notwendig
</div>
)}
</div>
)}
{/* Context */}
{selectedInteraction.linkedContent && (
<div className="context-info">
<h4>📺 Kontext</h4>
<p>
<a
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
>
{selectedInteraction.linkedContent.title}
</a>
</p>
</div>
)}
{/* Message */}
<div className="message-full">
<h4>Nachricht</h4>
<div className="message-content">
{selectedInteraction.message}
</div>
<div className="message-meta">
<span>{formatDate(selectedInteraction.publishedAt)}</span>
<span>❤️ {selectedInteraction.engagement?.likes || 0}</span>
<span>💬 {selectedInteraction.engagement?.replies || 0}</span>
</div>
</div>
{/* AI Analysis */}
{selectedInteraction.analysis && (
<div className="analysis-section">
<h4>🤖 KI-Analyse</h4>
<div className="analysis-grid">
<div className="analysis-item">
<label>Sentiment</label>
<span>
{getSentimentEmoji(selectedInteraction.analysis.sentiment)}
{' '}{selectedInteraction.analysis.sentiment}
{selectedInteraction.analysis.confidence && (
<small> ({selectedInteraction.analysis.confidence}%)</small>
)}
</span>
</div>
{selectedInteraction.analysis.topics?.length > 0 && (
<div className="analysis-item">
<label>Themen</label>
<div className="topic-tags">
{selectedInteraction.analysis.topics.map((t, i) => (
<span key={i} className="topic-tag">{t.topic}</span>
))}
</div>
</div>
)}
</div>
{selectedInteraction.analysis.suggestedReply && (
<div className="suggested-reply">
<label>Vorgeschlagene Antwort</label>
<p>{selectedInteraction.analysis.suggestedReply}</p>
<button
className="btn btn-small"
onClick={() => setReplyText(selectedInteraction.analysis.suggestedReply)}
>
Übernehmen
</button>
</div>
)}
</div>
)}
{/* Previous Response */}
{selectedInteraction.response?.text && (
<div className="previous-response">
<h4> Unsere Antwort</h4>
<div className="response-content">
{selectedInteraction.response.text}
</div>
<div className="response-meta">
Gesendet {formatDate(selectedInteraction.response.sentAt)}
{selectedInteraction.response.sentBy && (
<> von {selectedInteraction.response.sentBy.email}</>
)}
</div>
</div>
)}
{/* Reply Section */}
{!['resolved', 'archived', 'spam'].includes(selectedInteraction.status) && !selectedInteraction.response?.text && (
<div className="reply-section">
<h4>Antworten</h4>
{/* Template Picker */}
<div className="template-picker">
<label>Template:</label>
<select
onChange={(e) => {
const template = templates.find(t => t.id === Number(e.target.value))
if (template) applyTemplate(template)
}}
defaultValue=""
>
<option value="">Template wählen...</option>
{templates.map(template => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
<button
className="btn btn-ai"
onClick={generateAIReply}
title="KI-Antwort generieren"
>
🤖 KI-Antwort
</button>
</div>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Antwort schreiben..."
rows={4}
className="reply-textarea"
/>
<div className="reply-actions">
<span className="char-count">
{replyText.length} Zeichen
</span>
<button
className="btn btn-primary"
onClick={sendReply}
disabled={!replyText.trim() || sendingReply}
>
{sendingReply ? 'Sende...' : '📤 Antworten'}
</button>
</div>
</div>
)}
</>
) : (
<div className="no-selection">
<p>Wähle eine Interaktion aus der Liste</p>
</div>
)}
</div>
</div>
{/* Export Modal */}
{showExportModal && (
<div className="modal-overlay" onClick={() => setShowExportModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>📊 Report exportieren</h2>
<button className="modal-close" onClick={() => setShowExportModal(false)}>
</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>Format</label>
<div className="format-options">
<label className={`format-option ${exportFormat === 'excel' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="excel"
checked={exportFormat === 'excel'}
onChange={() => setExportFormat('excel')}
/>
<span className="format-icon">📗</span>
<span>Excel (.xlsx)</span>
</label>
<label className={`format-option ${exportFormat === 'csv' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="csv"
checked={exportFormat === 'csv'}
onChange={() => setExportFormat('csv')}
/>
<span className="format-icon">📄</span>
<span>CSV</span>
</label>
<label className={`format-option ${exportFormat === 'pdf' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="pdf"
checked={exportFormat === 'pdf'}
onChange={() => setExportFormat('pdf')}
/>
<span className="format-icon">📕</span>
<span>PDF</span>
</label>
</div>
</div>
<div className="form-group">
<label>Zeitraum</label>
<div className="date-inputs">
<div className="date-input">
<span>Von</span>
<input
type="date"
value={exportDateRange.from}
onChange={(e) => setExportDateRange(prev => ({ ...prev, from: e.target.value }))}
/>
</div>
<div className="date-input">
<span>Bis</span>
<input
type="date"
value={exportDateRange.to}
onChange={(e) => setExportDateRange(prev => ({ ...prev, to: e.target.value }))}
/>
</div>
</div>
</div>
<div className="form-group">
<label>Optionen</label>
<div className="export-info">
<p>Der Export enthält:</p>
<ul>
<li>Alle Interaktionen im gewählten Zeitraum</li>
<li>Aktuelle Filter werden berücksichtigt</li>
<li>Autor-Informationen, Sentiment, Status</li>
<li>Response-Daten (falls vorhanden)</li>
</ul>
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn"
onClick={() => setShowExportModal(false)}
>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
>
{exporting ? '⏳ Exportiere...' : '📥 Exportieren'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
```
### 1.3 Styles (Mobile + Desktop)
```scss
// src/app/(payload)/admin/views/community/inbox/inbox.scss
.community-inbox {
--color-urgent: #dc2626;
--color-high: #f97316;
--color-normal: #3b82f6;
--color-low: #22c55e;
--color-medical: #8b5cf6;
--color-border: #e5e7eb;
--color-bg-hover: #f9fafb;
--color-bg-selected: #eff6ff;
min-height: calc(100vh - 120px);
}
// ============================================
// DESKTOP STYLES
// ============================================
.community-inbox.desktop {
// Header
.inbox-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
h1 {
font-size: 1.5rem;
margin: 0;
}
.inbox-stats {
display: flex;
gap: 1.5rem;
margin-left: 2rem;
.stat {
font-size: 0.875rem;
color: #6b7280;
strong {
font-size: 1.125rem;
margin-right: 0.25rem;
}
&.stat-new strong { color: var(--color-normal); }
&.stat-urgent strong { color: var(--color-urgent); }
&.stat-medical strong { color: var(--color-medical); }
}
}
}
.inbox-actions {
display: flex;
gap: 0.5rem;
}
// Layout
.inbox-layout {
display: grid;
grid-template-columns: 240px 1fr 400px;
gap: 1rem;
height: calc(100vh - 200px);
@media (max-width: 1400px) {
grid-template-columns: 200px 1fr 350px;
}
@media (max-width: 1200px) {
grid-template-columns: 1fr 350px;
.inbox-sidebar {
display: none;
}
}
}
// Sidebar
.inbox-sidebar {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
overflow-y: auto;
.filter-section {
margin-bottom: 1.5rem;
h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
margin-bottom: 0.5rem;
}
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.875rem;
cursor: pointer;
input {
cursor: pointer;
}
.status-dot,
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
}
.filter-select,
.filter-search {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
.date-range {
display: flex;
flex-direction: column;
gap: 0.5rem;
input {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
}
.btn-reset {
width: 100%;
margin-top: 1rem;
}
}
// List
.inbox-list {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow-y: auto;
}
.interaction-list {
list-style: none;
margin: 0;
padding: 0;
}
.interaction-item {
padding: 1rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--color-bg-hover);
}
&.selected {
background: var(--color-bg-selected);
border-left: 3px solid var(--color-normal);
}
&.unread {
background: #fefce8;
.author-name {
font-weight: 600;
}
}
}
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.interaction-meta {
display: flex;
align-items: center;
gap: 0.5rem;
.priority-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
}
.platform-icon {
font-size: 1rem;
}
.author-name {
font-size: 0.875rem;
}
}
.interaction-time {
font-size: 0.75rem;
color: #9ca3af;
}
.interaction-content {
.message-preview {
font-size: 0.875rem;
color: #4b5563;
margin: 0;
line-height: 1.4;
}
}
.interaction-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.interaction-badges {
display: flex;
gap: 0.25rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
border-radius: 4px;
&.badge-status {
color: #fff;
}
&.badge-medical {
background: #f3e8ff;
color: var(--color-medical);
}
&.badge-escalation {
background: #fef2f2;
color: var(--color-urgent);
}
&.badge-influencer {
background: #fef3c7;
color: #d97706;
}
}
.video-reference {
font-size: 0.75rem;
color: #9ca3af;
}
// Detail Panel
.inbox-detail {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.author-info {
display: flex;
gap: 1rem;
.author-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
h3 {
margin: 0;
font-size: 1rem;
.verified-badge {
color: var(--color-normal);
margin-left: 0.25rem;
}
}
.author-handle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
}
.detail-actions {
display: flex;
gap: 0.5rem;
select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
}
.flags-alert {
margin-bottom: 1rem;
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 0.5rem;
&.alert-medical {
background: #f3e8ff;
color: #6b21a8;
border-left: 4px solid var(--color-medical);
}
&.alert-escalation {
background: #fef2f2;
color: #991b1b;
border-left: 4px solid var(--color-urgent);
}
}
}
.context-info {
background: #f9fafb;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
h4 {
font-size: 0.75rem;
color: #6b7280;
margin: 0 0 0.25rem;
}
a {
color: var(--color-normal);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.message-full {
margin-bottom: 1.5rem;
h4 {
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.5rem;
}
.message-content {
background: #f9fafb;
padding: 1rem;
border-radius: 6px;
font-size: 0.9375rem;
line-height: 1.6;
white-space: pre-wrap;
}
.message-meta {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
}
.analysis-section {
background: #f0f9ff;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
h4 {
font-size: 0.875rem;
margin: 0 0 0.75rem;
}
.analysis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.analysis-item {
label {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.topic-tag {
background: #dbeafe;
color: #1e40af;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.suggested-reply {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #bfdbfe;
label {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
p {
font-size: 0.875rem;
margin: 0 0 0.5rem;
}
}
}
.previous-response {
background: #f0fdf4;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
h4 {
font-size: 0.875rem;
color: #166534;
margin: 0 0 0.5rem;
}
.response-content {
font-size: 0.875rem;
line-height: 1.5;
}
.response-meta {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
}
}
.reply-section {
h4 {
font-size: 0.875rem;
margin: 0 0 0.75rem;
}
.template-picker {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
label {
font-size: 0.875rem;
color: #6b7280;
}
select {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
}
.reply-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.9375rem;
resize: vertical;
min-height: 100px;
&:focus {
outline: none;
border-color: var(--color-normal);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.reply-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
.char-count {
font-size: 0.75rem;
color: #9ca3af;
}
}
}
.no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
}
}
// ============================================
// MOBILE STYLES
// ============================================
.community-inbox.mobile {
padding: 0;
background: #f9fafb;
min-height: 100vh;
// Mobile Header
.mobile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #fff;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
h1 {
font-size: 1.25rem;
margin: 0;
}
.mobile-header-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
position: relative;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f3f4f6;
border-radius: 8px;
font-size: 1.125rem;
cursor: pointer;
&:disabled {
opacity: 0.5;
}
.filter-badge {
position: absolute;
top: -4px;
right: -4px;
background: var(--color-urgent);
color: #fff;
font-size: 0.625rem;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
.btn-back {
background: none;
border: none;
font-size: 1rem;
color: var(--color-normal);
cursor: pointer;
padding: 0.5rem;
}
.mobile-title {
font-weight: 600;
}
.btn-text {
background: none;
border: none;
color: var(--color-normal);
font-size: 0.875rem;
cursor: pointer;
}
}
// Mobile Stats
.mobile-stats {
display: flex;
justify-content: space-around;
padding: 0.75rem;
background: #fff;
border-bottom: 1px solid var(--color-border);
.stat {
text-align: center;
font-size: 1.125rem;
font-weight: 600;
small {
display: block;
font-size: 0.625rem;
font-weight: 400;
color: #6b7280;
text-transform: uppercase;
}
&.urgent { color: var(--color-urgent); }
&.medical { color: var(--color-medical); }
}
}
// Mobile Search
.mobile-search {
padding: 0.75rem;
background: #fff;
input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: #f9fafb;
&:focus {
outline: none;
border-color: var(--color-normal);
background: #fff;
}
}
}
// Mobile Filters
.mobile-filters {
padding: 1rem;
background: #fff;
min-height: calc(100vh - 60px);
.filter-section {
margin-bottom: 1.5rem;
h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.75rem;
}
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.chip {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: 20px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&.active {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
}
}
}
.mobile-select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: #fff;
}
.btn-full {
width: 100%;
margin-top: 1rem;
}
}
// Mobile List
.mobile-list {
min-height: calc(100vh - 200px);
ul {
list-style: none;
margin: 0;
padding: 0;
}
.mobile-item {
background: #fff;
margin-bottom: 1px;
&.unread {
background: #fffbeb;
}
.item-row {
display: flex;
align-items: stretch;
padding: 1rem;
gap: 0.75rem;
}
.priority-bar {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
.author {
font-weight: 500;
font-size: 0.9375rem;
}
.time {
font-size: 0.75rem;
color: #9ca3af;
}
}
.message {
font-size: 0.875rem;
color: #4b5563;
margin: 0 0 0.5rem;
line-height: 1.4;
}
.item-badges {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
.badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
border-radius: 4px;
&.status {
color: #fff;
}
&.medical {
background: #f3e8ff;
}
&.urgent {
background: #fef2f2;
}
}
}
.chevron {
display: flex;
align-items: center;
color: #9ca3af;
font-size: 1.25rem;
}
}
}
// Mobile Detail
.mobile-detail {
padding: 1rem;
background: #f9fafb;
min-height: calc(100vh - 60px);
.author-card {
display: flex;
gap: 1rem;
padding: 1rem;
background: #fff;
border-radius: 12px;
margin-bottom: 1rem;
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.author-info {
h3 {
margin: 0;
font-size: 1rem;
}
p {
margin: 0;
font-size: 0.75rem;
color: #6b7280;
}
}
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
select {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
background: #fff;
}
}
.mobile-alerts {
margin-bottom: 1rem;
.alert {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 0.5rem;
&.medical {
background: #f3e8ff;
color: #6b21a8;
}
&.urgent {
background: #fef2f2;
color: #991b1b;
}
}
}
.context-link {
display: block;
padding: 0.75rem 1rem;
background: #f0f9ff;
border-radius: 8px;
color: var(--color-normal);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.message-card {
background: #fff;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
p {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
line-height: 1.5;
white-space: pre-wrap;
}
.message-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
}
}
.analysis-card {
background: #f0f9ff;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.analysis-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
.topics {
display: flex;
gap: 0.25rem;
.topic {
background: #dbeafe;
color: #1e40af;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
}
}
}
.response-card {
background: #f0fdf4;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
h4 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #166534;
}
p {
margin: 0;
font-size: 0.875rem;
}
small {
color: #6b7280;
}
}
.reply-section {
background: #fff;
padding: 1rem;
border-radius: 12px;
.reply-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
select {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
}
.btn-ai {
padding: 0.75rem 1rem;
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 8px;
color: #7c3aed;
font-size: 0.875rem;
cursor: pointer;
}
}
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
resize: vertical;
min-height: 100px;
margin-bottom: 0.75rem;
&:focus {
outline: none;
border-color: var(--color-normal);
}
}
.btn-full {
width: 100%;
}
}
}
// Mobile Modal
.mobile-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.mobile-modal {
background: #fff;
width: 100%;
border-radius: 16px 16px 0 0;
padding: 1.5rem;
max-height: 80vh;
overflow-y: auto;
h3 {
margin: 0 0 1.5rem;
text-align: center;
}
.modal-field {
margin-bottom: 1.5rem;
label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
margin-bottom: 0.5rem;
}
}
.format-buttons {
display: flex;
gap: 0.5rem;
button {
flex: 1;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
&.active {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
}
}
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
button {
flex: 1;
padding: 1rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
&:first-child {
background: #f3f4f6;
border: none;
}
&.btn-primary {
background: var(--color-normal);
color: #fff;
border: none;
}
}
}
}
}
// ============================================
// SHARED STYLES
// ============================================
// Buttons
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: #f9fafb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-primary {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
&:hover:not(:disabled) {
background: #2563eb;
}
}
&.btn-sync {
background: #f0f9ff;
border-color: #bfdbfe;
color: #1e40af;
}
&.btn-export {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
}
&.btn-ai {
background: #faf5ff;
border-color: #e9d5ff;
color: #7c3aed;
}
&.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
}
// Loading
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #9ca3af;
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: var(--color-normal);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #9ca3af;
button {
margin-top: 1rem;
}
}
// Desktop Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border-radius: 12px;
width: 100%;
max-width: 500px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
h2 {
margin: 0;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #6b7280;
&:hover {
color: #1f2937;
}
}
}
.modal-body {
padding: 1.5rem;
.form-group {
margin-bottom: 1.5rem;
> label {
display: block;
font-weight: 500;
margin-bottom: 0.75rem;
}
}
.format-options {
display: flex;
gap: 1rem;
.format-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border: 2px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
input {
display: none;
}
.format-icon {
font-size: 2rem;
}
&.selected {
border-color: var(--color-normal);
background: #f0f9ff;
}
&:hover {
border-color: var(--color-normal);
}
}
}
.date-inputs {
display: flex;
gap: 1rem;
.date-input {
flex: 1;
span {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.875rem;
}
}
}
.export-info {
background: #f9fafb;
padding: 1rem;
border-radius: 6px;
font-size: 0.875rem;
p {
margin: 0 0 0.5rem;
font-weight: 500;
}
ul {
margin: 0;
padding-left: 1.25rem;
color: #6b7280;
li {
margin-bottom: 0.25rem;
}
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border);
background: #f9fafb;
border-radius: 0 0 12px 12px;
}
}
```
---
## Teil 2: Export API Endpoint
### 2.1 Export Route Handler
```typescript
// src/app/(payload)/api/community/export/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import ExcelJS from 'exceljs'
import PDFDocument from 'pdfkit'
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams
const format = searchParams.get('format') || 'excel'
const dateFrom = searchParams.get('dateFrom')
const dateTo = searchParams.get('dateTo')
const status = searchParams.get('status')?.split(',')
const platform = searchParams.get('platform')?.split(',')
const sentiment = searchParams.get('sentiment')?.split(',')
const payload = await getPayload({ config })
// Build query
const where: any = {}
if (dateFrom || dateTo) {
where.publishedAt = {}
if (dateFrom) where.publishedAt.greater_than_equal = dateFrom
if (dateTo) where.publishedAt.less_than_equal = dateTo
}
if (status?.length) {
where.status = { in: status }
}
if (platform?.length) {
where.platform = { in: platform }
}
if (sentiment?.length) {
where['analysis.sentiment'] = { in: sentiment }
}
// Fetch interactions
const interactions = await payload.find({
collection: 'community-interactions',
where,
sort: '-publishedAt',
limit: 1000,
depth: 2,
})
// Generate export based on format
switch (format) {
case 'excel':
return generateExcel(interactions.docs)
case 'csv':
return generateCSV(interactions.docs)
case 'pdf':
return generatePDF(interactions.docs)
default:
return NextResponse.json({ error: 'Invalid format' }, { status: 400 })
}
} catch (error: any) {
console.error('Export error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
// Excel Export
async function generateExcel(interactions: any[]): Promise<NextResponse> {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Community Hub'
workbook.created = new Date()
// Main Data Sheet
const dataSheet = workbook.addWorksheet('Interactions', {
views: [{ state: 'frozen', ySplit: 1 }],
})
dataSheet.columns = [
{ header: 'ID', key: 'id', width: 8 },
{ header: 'Datum', key: 'date', width: 12 },
{ header: 'Plattform', key: 'platform', width: 12 },
{ header: 'Kanal', key: 'channel', width: 20 },
{ header: 'Autor', key: 'author', width: 20 },
{ header: 'Follower', key: 'followers', width: 12 },
{ header: 'Nachricht', key: 'message', width: 50 },
{ header: 'Sentiment', key: 'sentiment', width: 12 },
{ header: 'Score', key: 'score', width: 8 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Priorität', key: 'priority', width: 10 },
{ header: 'Medical', key: 'medical', width: 8 },
{ header: 'Eskalation', key: 'escalation', width: 10 },
{ header: 'Influencer', key: 'influencer', width: 10 },
{ header: 'Zugewiesen', key: 'assigned', width: 20 },
{ header: 'Beantwortet', key: 'repliedAt', width: 12 },
{ header: 'Antwort', key: 'response', width: 50 },
{ header: 'Likes', key: 'likes', width: 8 },
{ header: 'Replies', key: 'replies', width: 8 },
]
// Style header row
dataSheet.getRow(1).font = { bold: true }
dataSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF3B82F6' },
}
dataSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
// Add data rows
for (const interaction of interactions) {
dataSheet.addRow({
id: interaction.id,
date: new Date(interaction.publishedAt).toLocaleDateString('de-DE'),
platform: interaction.platform?.name || '',
channel: interaction.socialAccount?.displayName || '',
author: interaction.author?.name || '',
followers: interaction.author?.subscriberCount || 0,
message: interaction.message?.substring(0, 500) || '',
sentiment: interaction.analysis?.sentiment || '',
score: interaction.analysis?.sentimentScore || 0,
status: interaction.status,
priority: interaction.priority,
medical: interaction.flags?.isMedicalQuestion ? 'Ja' : '',
escalation: interaction.flags?.requiresEscalation ? 'Ja' : '',
influencer: interaction.flags?.isFromInfluencer ? 'Ja' : '',
assigned: interaction.assignedTo?.email || '',
repliedAt: interaction.response?.sentAt
? new Date(interaction.response.sentAt).toLocaleDateString('de-DE')
: '',
response: interaction.response?.text?.substring(0, 500) || '',
likes: interaction.engagement?.likes || 0,
replies: interaction.engagement?.replies || 0,
})
}
// Conditional formatting for priority
dataSheet.eachRow((row, rowNumber) => {
if (rowNumber > 1) {
const priorityCell = row.getCell('priority')
if (priorityCell.value === 'urgent') {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFEF2F2' },
}
}
}
})
// Summary Sheet
const summarySheet = workbook.addWorksheet('Zusammenfassung')
const sentimentCounts = {
positive: interactions.filter(i => i.analysis?.sentiment === 'positive').length,
neutral: interactions.filter(i => i.analysis?.sentiment === 'neutral').length,
negative: interactions.filter(i => i.analysis?.sentiment === 'negative').length,
mixed: interactions.filter(i => i.analysis?.sentiment === 'mixed').length,
}
// Status-Werte gemäß CommunityInteractions.ts
const statusCounts = {
new: interactions.filter(i => i.status === 'new').length,
in_review: interactions.filter(i => i.status === 'in_review').length,
waiting: interactions.filter(i => i.status === 'waiting').length,
replied: interactions.filter(i => i.status === 'replied').length,
resolved: interactions.filter(i => i.status === 'resolved').length,
archived: interactions.filter(i => i.status === 'archived').length,
spam: interactions.filter(i => i.status === 'spam').length,
}
summarySheet.addRow(['Community Report'])
summarySheet.addRow(['Erstellt am', new Date().toLocaleString('de-DE')])
summarySheet.addRow(['Zeitraum', interactions.length > 0
? `${new Date(interactions[interactions.length - 1].publishedAt).toLocaleDateString('de-DE')} - ${new Date(interactions[0].publishedAt).toLocaleDateString('de-DE')}`
: 'N/A'
])
summarySheet.addRow([])
summarySheet.addRow(['Gesamt Interaktionen', interactions.length])
summarySheet.addRow([])
summarySheet.addRow(['Sentiment Verteilung'])
summarySheet.addRow(['Positiv', sentimentCounts.positive])
summarySheet.addRow(['Neutral', sentimentCounts.neutral])
summarySheet.addRow(['Negativ', sentimentCounts.negative])
summarySheet.addRow(['Gemischt', sentimentCounts.mixed])
summarySheet.addRow([])
summarySheet.addRow(['Status Verteilung'])
summarySheet.addRow(['Neu', statusCounts.new])
summarySheet.addRow(['In Review', statusCounts.in_review])
summarySheet.addRow(['Warten auf Info', statusCounts.waiting])
summarySheet.addRow(['Beantwortet', statusCounts.replied])
summarySheet.addRow(['Erledigt', statusCounts.resolved])
summarySheet.addRow(['Archiviert', statusCounts.archived])
summarySheet.addRow(['Spam', statusCounts.spam])
summarySheet.addRow([])
summarySheet.addRow(['Flags'])
summarySheet.addRow(['Medizinische Fragen', interactions.filter(i => i.flags?.isMedicalQuestion).length])
summarySheet.addRow(['Eskalationen', interactions.filter(i => i.flags?.requiresEscalation).length])
summarySheet.addRow(['Influencer', interactions.filter(i => i.flags?.isFromInfluencer).length])
// Style summary
summarySheet.getRow(1).font = { bold: true, size: 16 }
summarySheet.getColumn(1).width = 25
summarySheet.getColumn(2).width = 20
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer()
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.xlsx"`,
},
})
}
// CSV Export
async function generateCSV(interactions: any[]): Promise<NextResponse> {
const headers = [
'ID',
'Datum',
'Plattform',
'Kanal',
'Autor',
'Follower',
'Nachricht',
'Sentiment',
'Score',
'Status',
'Priorität',
'Medical',
'Eskalation',
'Influencer',
'Zugewiesen',
'Beantwortet',
'Antwort',
'Likes',
'Replies',
]
const rows = interactions.map(interaction => [
interaction.id,
new Date(interaction.publishedAt).toISOString(),
interaction.platform?.name || '',
interaction.socialAccount?.displayName || '',
interaction.author?.name || '',
interaction.author?.subscriberCount || 0,
`"${(interaction.message || '').replace(/"/g, '""').substring(0, 500)}"`,
interaction.analysis?.sentiment || '',
interaction.analysis?.sentimentScore || 0,
interaction.status,
interaction.priority,
interaction.flags?.isMedicalQuestion ? 'Ja' : '',
interaction.flags?.requiresEscalation ? 'Ja' : '',
interaction.flags?.isFromInfluencer ? 'Ja' : '',
interaction.assignedTo?.email || '',
interaction.response?.sentAt || '',
`"${(interaction.response?.text || '').replace(/"/g, '""').substring(0, 500)}"`,
interaction.engagement?.likes || 0,
interaction.engagement?.replies || 0,
])
const csv = [headers.join(';'), ...rows.map(row => row.join(';'))].join('\n')
// Add BOM for Excel compatibility with German characters
const bom = '\uFEFF'
return new NextResponse(bom + csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.csv"`,
},
})
}
// PDF Export
async function generatePDF(interactions: any[]): Promise<NextResponse> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
margin: 50,
size: 'A4',
})
const chunks: Buffer[] = []
doc.on('data', (chunk) => chunks.push(chunk))
doc.on('end', () => {
const buffer = Buffer.concat(chunks)
resolve(new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.pdf"`,
},
}))
})
// Title
doc.fontSize(24).text('Community Report', { align: 'center' })
doc.moveDown()
doc.fontSize(12).text(`Erstellt am: ${new Date().toLocaleString('de-DE')}`, { align: 'center' })
doc.moveDown(2)
// Summary Stats
const sentimentCounts = {
positive: interactions.filter(i => i.analysis?.sentiment === 'positive').length,
negative: interactions.filter(i => i.analysis?.sentiment === 'negative').length,
neutral: interactions.filter(i => i.analysis?.sentiment === 'neutral').length,
}
// Status-Werte gemäß CommunityInteractions.ts
const statusCounts = {
new: interactions.filter(i => i.status === 'new').length,
replied: interactions.filter(i => i.status === 'replied').length,
resolved: interactions.filter(i => i.status === 'resolved').length,
}
doc.fontSize(16).text('Übersicht', { underline: true })
doc.moveDown()
doc.fontSize(12)
doc.text(`Gesamt Interaktionen: ${interactions.length}`)
doc.text(`Davon neu: ${statusCounts.new}`)
doc.text(`Davon beantwortet: ${statusCounts.replied}`)
doc.text(`Davon erledigt: ${statusCounts.resolved}`)
doc.moveDown()
doc.text(`Positives Sentiment: ${sentimentCounts.positive}`)
doc.text(`Neutrales Sentiment: ${sentimentCounts.neutral}`)
doc.text(`Negatives Sentiment: ${sentimentCounts.negative}`)
doc.moveDown()
// Flags
const medicalCount = interactions.filter(i => i.flags?.isMedicalQuestion).length
const escalationCount = interactions.filter(i => i.flags?.requiresEscalation).length
const influencerCount = interactions.filter(i => i.flags?.isFromInfluencer).length
doc.text(`Medizinische Fragen: ${medicalCount}`)
doc.text(`Eskalationen: ${escalationCount}`)
doc.text(`Von Influencern: ${influencerCount}`)
doc.moveDown(2)
// Recent interactions
doc.fontSize(16).text('Letzte 20 Interaktionen', { underline: true })
doc.moveDown()
const recentInteractions = interactions.slice(0, 20)
for (const interaction of recentInteractions) {
// Check if we need a new page
if (doc.y > 700) {
doc.addPage()
}
doc.fontSize(10)
.fillColor('#6b7280')
.text(
`${new Date(interaction.publishedAt).toLocaleDateString('de-DE')} | ${interaction.platform?.name || 'Unknown'} | ${interaction.author?.name || 'Unknown'}`,
{ continued: false }
)
doc.fontSize(11)
.fillColor('#000000')
.text(
interaction.message?.substring(0, 200) + (interaction.message?.length > 200 ? '...' : ''),
{ continued: false }
)
const badges = []
if (interaction.analysis?.sentiment) badges.push(interaction.analysis.sentiment)
if (interaction.flags?.isMedicalQuestion) badges.push('Medical')
if (interaction.flags?.requiresEscalation) badges.push('Eskalation')
doc.fontSize(9)
.fillColor('#3b82f6')
.text(`Status: ${interaction.status} | ${badges.join(' | ')}`)
doc.moveDown()
}
// Footer
const pageCount = doc.bufferedPageRange().count
for (let i = 0; i < pageCount; i++) {
doc.switchToPage(i)
doc.fontSize(8)
.fillColor('#9ca3af')
.text(
`Seite ${i + 1} von ${pageCount} | Community Hub Report`,
50,
doc.page.height - 50,
{ align: 'center' }
)
}
doc.end()
} catch (error) {
reject(error)
}
})
}
```
### 2.2 Package Dependencies
```json
{
"dependencies": {
"exceljs": "^4.4.0",
"pdfkit": "^0.15.0"
},
"devDependencies": {
"@types/pdfkit": "^0.13.4"
}
}
```
---
## Teil 3: YouTube OAuth Flow
```typescript
// src/lib/integrations/youtube/oauth.ts
import { google } from 'googleapis'
const SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube',
]
export function getOAuth2Client() {
return new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
process.env.YOUTUBE_REDIRECT_URI || `${process.env.NEXT_PUBLIC_SERVER_URL}/api/youtube/callback`
)
}
export function getAuthUrl(state?: string) {
const oauth2Client = getOAuth2Client()
return oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent',
state: state,
})
}
export async function getTokensFromCode(code: string) {
const oauth2Client = getOAuth2Client()
const { tokens } = await oauth2Client.getToken(code)
return tokens
}
export async function refreshAccessToken(refreshToken: string) {
const oauth2Client = getOAuth2Client()
oauth2Client.setCredentials({ refresh_token: refreshToken })
const { credentials } = await oauth2Client.refreshAccessToken()
return credentials
}
```
```typescript
// src/app/(payload)/api/youtube/auth/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getAuthUrl } from '@/lib/integrations/youtube/oauth'
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const socialAccountId = searchParams.get('socialAccountId')
if (!socialAccountId) {
return NextResponse.json(
{ error: 'socialAccountId query parameter required' },
{ status: 400 }
)
}
const state = Buffer.from(JSON.stringify({ socialAccountId })).toString('base64')
const authUrl = getAuthUrl(state)
return NextResponse.redirect(authUrl)
}
```
```typescript
// src/app/(payload)/api/youtube/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getTokensFromCode } from '@/lib/integrations/youtube/oauth'
import { google } from 'googleapis'
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (error) {
console.error('OAuth error:', error)
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=${error}`
)
}
if (!code || !state) {
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=missing_params`
)
}
try {
const { socialAccountId } = JSON.parse(Buffer.from(state, 'base64').toString())
const tokens = await getTokensFromCode(code)
const oauth2Client = new google.auth.OAuth2()
oauth2Client.setCredentials(tokens)
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
const channelResponse = await youtube.channels.list({
part: ['snippet', 'statistics'],
mine: true,
})
const channel = channelResponse.data.items?.[0]
const payload = await getPayload({ config })
await payload.update({
collection: 'social-accounts',
id: socialAccountId,
data: {
externalId: channel?.id,
accountHandle: channel?.snippet?.customUrl || `@${channel?.snippet?.title}`,
credentials: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiresAt: tokens.expiry_date
? new Date(tokens.expiry_date).toISOString()
: null,
},
stats: {
followers: parseInt(channel?.statistics?.subscriberCount || '0'),
totalPosts: parseInt(channel?.statistics?.videoCount || '0'),
lastSyncedAt: new Date().toISOString(),
},
},
})
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts/${socialAccountId}?success=connected`
)
} catch (err: any) {
console.error('OAuth callback error:', err)
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=${encodeURIComponent(err.message)}`
)
}
}
```
```typescript
// src/app/(payload)/api/youtube/refresh-token/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { refreshAccessToken } from '@/lib/integrations/youtube/oauth'
export async function POST(req: NextRequest) {
try {
const { socialAccountId } = await req.json()
const payload = await getPayload({ config })
const account = await payload.findByID({
collection: 'social-accounts',
id: socialAccountId,
})
if (!account?.credentials?.refreshToken) {
return NextResponse.json(
{ error: 'No refresh token available' },
{ status: 400 }
)
}
const credentials = await refreshAccessToken(account.credentials.refreshToken)
await payload.update({
collection: 'social-accounts',
id: socialAccountId,
data: {
credentials: {
accessToken: credentials.access_token,
refreshToken: credentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: credentials.expiry_date
? new Date(credentials.expiry_date).toISOString()
: null,
},
},
})
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Token refresh error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
```
---
## Teil 4: Auto-Sync Cron Job
```typescript
// src/jobs/syncAllComments.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { CommentsSyncService } from '@/lib/integrations/youtube/CommentsSyncService'
import { refreshAccessToken } from '@/lib/integrations/youtube/oauth'
async function main() {
console.log('🔄 Starting scheduled comment sync...')
console.log(` Time: ${new Date().toISOString()}`)
const payload = await getPayload({ config })
const syncService = new CommentsSyncService(payload)
const accounts = await payload.find({
collection: 'social-accounts',
where: {
and: [
{ isActive: { equals: true } },
{ 'syncSettings.autoSyncEnabled': { equals: true } },
{ 'credentials.accessToken': { exists: true } },
],
},
limit: 100,
})
console.log(`📊 Found ${accounts.docs.length} accounts to sync`)
let totalNew = 0
let totalUpdated = 0
let errors: string[] = []
for (const account of accounts.docs) {
try {
console.log(`\n📺 Syncing: ${account.displayName}`)
const expiresAt = account.credentials?.tokenExpiresAt
if (expiresAt && new Date(expiresAt) < new Date()) {
console.log(' ⏰ Token expired, refreshing...')
try {
const newCredentials = await refreshAccessToken(account.credentials.refreshToken)
await payload.update({
collection: 'social-accounts',
id: account.id,
data: {
credentials: {
accessToken: newCredentials.access_token,
refreshToken: newCredentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: newCredentials.expiry_date
? new Date(newCredentials.expiry_date).toISOString()
: null,
},
},
})
console.log(' ✅ Token refreshed')
} catch (refreshError) {
console.error(' ❌ Token refresh failed:', refreshError)
errors.push(`${account.displayName}: Token refresh failed`)
continue
}
}
const result = await syncService.syncCommentsForAccount(account.id, {
maxComments: 100,
analyzeWithAI: true,
})
totalNew += result.created || 0
totalUpdated += result.updated || 0
console.log(` ✅ New: ${result.created || 0}, Updated: ${result.updated || 0}`)
if (result.errors?.length > 0) {
errors.push(...result.errors.map((e: string) => `${account.displayName}: ${e}`))
}
} catch (accountError: any) {
console.error(` ❌ Failed: ${accountError.message}`)
errors.push(`${account.displayName}: ${accountError.message}`)
}
}
console.log('\n' + '='.repeat(50))
console.log('📊 SYNC SUMMARY')
console.log('='.repeat(50))
console.log(` New comments: ${totalNew}`)
console.log(` Updated: ${totalUpdated}`)
console.log(` Errors: ${errors.length}`)
if (errors.length > 0) {
console.log('\n⚠ Errors:')
errors.forEach(e => console.log(` - ${e}`))
}
console.log('\n🏁 Sync complete!')
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('Fatal error:', err)
process.exit(1)
})
```
---
## Teil 5: Rules Engine
```typescript
// src/lib/services/RulesEngine.ts
import type { Payload } from 'payload'
interface RuleAction {
action: string
value?: string
targetUser?: number | { id: number }
targetTemplate?: number | { id: number }
}
interface Rule {
id: number
name: string
priority: number
isActive: boolean
platforms?: any[]
channel?: any
trigger: {
type: string
keywords?: { keyword: string; matchType: string }[]
sentimentValues?: string[]
influencerMinFollowers?: number
}
actions: RuleAction[]
}
export class RulesEngine {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
async evaluateRules(interactionId: number): Promise<{
appliedRules: string[]
changes: Record<string, any>
}> {
const appliedRules: string[] = []
const changes: Record<string, any> = {}
const interaction = await this.payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2,
})
if (!interaction) {
throw new Error('Interaction not found')
}
const rules = await this.payload.find({
collection: 'community-rules',
where: {
isActive: { equals: true },
},
sort: 'priority',
limit: 100,
})
for (const rule of rules.docs as Rule[]) {
if (rule.platforms?.length > 0) {
const platformIds = rule.platforms.map((p: any) =>
typeof p === 'object' ? p.id : p
)
const interactionPlatformId = typeof interaction.platform === 'object'
? interaction.platform.id
: interaction.platform
if (!platformIds.includes(interactionPlatformId)) {
continue
}
}
const triggered = await this.checkTrigger(rule, interaction)
if (triggered) {
const ruleChanges = await this.applyActions(rule, interaction)
Object.assign(changes, ruleChanges)
appliedRules.push(rule.name)
await this.payload.update({
collection: 'community-rules',
id: rule.id,
data: {
stats: {
timesTriggered: (rule as any).stats?.timesTriggered + 1 || 1,
lastTriggeredAt: new Date().toISOString(),
},
},
})
}
}
if (Object.keys(changes).length > 0) {
await this.payload.update({
collection: 'community-interactions',
id: interactionId,
data: changes,
})
}
return { appliedRules, changes }
}
private async checkTrigger(rule: Rule, interaction: any): Promise<boolean> {
const { trigger } = rule
const message = interaction.message?.toLowerCase() || ''
switch (trigger.type) {
case 'keyword':
if (!trigger.keywords?.length) return false
return trigger.keywords.some(({ keyword, matchType }) => {
const kw = keyword.toLowerCase()
switch (matchType) {
case 'exact':
return message === kw
case 'regex':
try {
return new RegExp(keyword, 'i').test(interaction.message)
} catch {
return false
}
case 'contains':
default:
return message.includes(kw)
}
})
case 'sentiment':
if (!trigger.sentimentValues?.length) return false
return trigger.sentimentValues.includes(interaction.analysis?.sentiment)
case 'influencer':
const minFollowers = trigger.influencerMinFollowers || 10000
return (interaction.author?.subscriberCount || 0) >= minFollowers
case 'medical':
return interaction.flags?.isMedicalQuestion === true
case 'new_subscriber':
return interaction.author?.isSubscriber === true
default:
return false
}
}
private async applyActions(rule: Rule, interaction: any): Promise<Record<string, any>> {
const changes: Record<string, any> = {}
for (const action of rule.actions) {
switch (action.action) {
case 'set_priority':
if (action.value) {
changes.priority = action.value
}
break
case 'assign_to':
const userId = typeof action.targetUser === 'object'
? action.targetUser.id
: action.targetUser
if (userId) {
changes.assignedTo = userId
}
break
case 'apply_template':
const templateId = typeof action.targetTemplate === 'object'
? action.targetTemplate.id
: action.targetTemplate
if (templateId) {
changes['analysis.suggestedTemplate'] = templateId
}
break
case 'flag':
if (action.value === 'medical') {
changes['flags.isMedicalQuestion'] = true
} else if (action.value === 'escalation') {
changes['flags.requiresEscalation'] = true
} else if (action.value === 'spam') {
changes['flags.isSpam'] = true
} else if (action.value === 'influencer') {
changes['flags.isFromInfluencer'] = true
}
break
case 'notify':
await this.sendNotification(action, interaction, rule)
break
}
}
return changes
}
private async sendNotification(
action: RuleAction,
interaction: any,
rule: Rule
): Promise<void> {
const userId = typeof action.targetUser === 'object'
? action.targetUser.id
: action.targetUser
if (!userId) return
try {
await this.payload.create({
collection: 'yt-notifications',
data: {
user: userId,
type: 'community_rule_triggered',
title: `🔔 Rule "${rule.name}" triggered`,
message: `New interaction from ${interaction.author?.name}: "${interaction.message?.substring(0, 100)}..."`,
relatedContent: interaction.linkedContent?.id,
priority: interaction.priority === 'urgent' ? 'urgent' : 'normal',
isRead: false,
},
})
} catch (error) {
console.error('Failed to create notification:', error)
}
}
}
```
---
## Implementierungs-Checkliste
### Community Inbox View (inkl. Mobile)
- [ ] `src/app/(payload)/admin/views/community/inbox/page.tsx`
- [ ] `src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx`
- [ ] `src/app/(payload)/admin/views/community/inbox/inbox.scss`
- [ ] `src/app/(payload)/api/community-interactions/stats/route.ts`
- [ ] `src/app/(payload)/api/community/generate-reply/route.ts`
- [ ] Payload config: View registration
- [ ] Navigation link hinzufügen
- [ ] Mobile testing (verschiedene Devices)
### Export Funktion
- [ ] `src/app/(payload)/api/community/export/route.ts`
- [ ] npm install exceljs pdfkit @types/pdfkit
- [ ] Excel export testen
- [ ] CSV export testen
- [ ] PDF export testen
### YouTube OAuth
- [ ] `src/lib/integrations/youtube/oauth.ts`
- [ ] `src/app/(payload)/api/youtube/auth/route.ts`
- [ ] `src/app/(payload)/api/youtube/callback/route.ts`
- [ ] `src/app/(payload)/api/youtube/refresh-token/route.ts`
- [ ] Environment variables setzen
- [ ] Google Cloud Console konfigurieren
### Auto-Sync Cron
- [ ] `src/jobs/syncAllComments.ts`
- [ ] package.json script hinzufügen
- [ ] Cron oder Systemd Timer einrichten
### Rules Engine
- [ ] `src/lib/services/RulesEngine.ts`
- [ ] Hook in CommunityInteractions hinzufügen
---
## Mobile-spezifische Features
| Feature | Beschreibung |
|---------|--------------|
| **Touch-optimiert** | Größere Touch-Targets, Swipe-Gesten möglich |
| **Fullscreen Views** | Liste/Detail/Filter als separate Screens |
| **Quick Stats** | Kompakte Stats-Bar im Header |
| **Chip-basierte Filter** | Einfach tippbare Filter-Chips |
| **Bottom-Sheet Modal** | Native-feeling Export-Modal |
| **Optimierte Typografie** | Größere Fonts, bessere Lesbarkeit |
| **Sticky Header** | Navigation immer sichtbar |
| **Pull-to-Refresh** | Könnte noch ergänzt werden |
---
## Export-Formate
| Format | Inhalt | Verwendung |
|--------|--------|------------|
| **Excel** | 2 Sheets (Daten + Summary), formatiert | Management Reports |
| **CSV** | Rohdaten, BOM für DE-Zeichen | Weiterverarbeitung |
| **PDF** | Übersicht + Top 20 Interaktionen | Präsentationen |
---
## Geschätzter Aufwand (Updated)
| Teil | Tage |
|------|------|
| Community Inbox (Desktop) | 3 |
| Mobile Optimierung | 1.5 |
| Export Funktion | 1 |
| YouTube OAuth | 1 |
| Auto-Sync Cron | 0.5 |
| Rules Engine | 1.5 |
| Testing | 1 |
| **Gesamt** | **9-10 Tage** |