← back to Norma
components/community/CommunityTab.tsx
680 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageSquare,
ArrowUpDown,
RefreshCw,
Search,
Clock,
Flame,
TrendingUp,
ChevronDown,
ChevronUp,
ExternalLink,
Users,
AlertTriangle,
ThumbsUp,
GraduationCap,
DollarSign,
Filter,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList } from '../Skeleton';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface CommunityPost {
id: string;
platform: string;
source_id: string;
subreddit: string | null;
author: string | null;
title: string;
body: string | null;
url: string | null;
score: number;
num_comments: number;
posted_at: string | null;
mentions_servicer: string | null;
mentions_program: string | null;
debt_amount: number | null;
sentiment: string | null;
is_alumni: boolean;
school_mentioned: string | null;
tags: string[];
heat_score: number;
}
interface SubredditStat {
subreddit: string;
cnt: string;
avg_score: string;
}
interface SentimentStat {
sentiment: string;
cnt: string;
}
type SortMode = 'heat' | 'date' | 'score';
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function timeAgo(dateStr: string | null): string {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function sentimentColor(sentiment: string | null): string {
switch (sentiment) {
case 'frustrated': return '#ef4444';
case 'negative': return '#f59e0b';
case 'positive': return '#22c55e';
default: return '#71717a';
}
}
function sentimentIcon(sentiment: string | null) {
switch (sentiment) {
case 'frustrated': return <AlertTriangle size={12} />;
case 'positive': return <ThumbsUp size={12} />;
default: return null;
}
}
function formatDebtAmount(amount: number | null): string {
if (!amount) return '';
if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`;
if (amount >= 1_000) return `$${(amount / 1_000).toFixed(0)}k`;
return `$${amount}`;
}
/* ─── Post Card ──────────────────────────────────────────────────────────── */
function PostCard({ post }: { post: CommunityPost }) {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="card"
style={{
borderLeft: `3px solid ${sentimentColor(post.sentiment)}`,
cursor: 'pointer',
}}
onClick={() => setExpanded((e) => !e)}
>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
{/* Heat score badge */}
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: post.heat_score > 100
? 'linear-gradient(135deg, #ef4444, #f97316)'
: post.heat_score > 50
? 'linear-gradient(135deg, #f59e0b, #eab308)'
: 'var(--color-surface-el)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Flame size={12} style={{ color: post.heat_score > 50 ? '#fff' : 'var(--color-text-muted)' }} />
<span
style={{
fontSize: 11,
fontWeight: 800,
color: post.heat_score > 50 ? '#fff' : 'var(--color-text-secondary)',
lineHeight: 1,
}}
>
{post.heat_score}
</span>
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, flexWrap: 'wrap' }}>
{post.subreddit && (
<span
style={{
fontSize: 10,
fontWeight: 700,
color: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.1)',
padding: '2px 6px',
borderRadius: 4,
}}
>
r/{post.subreddit}
</span>
)}
{post.sentiment && (
<span
style={{
fontSize: 10,
fontWeight: 600,
color: sentimentColor(post.sentiment),
display: 'flex',
alignItems: 'center',
gap: 3,
}}
>
{sentimentIcon(post.sentiment)}
{post.sentiment}
</span>
)}
{post.is_alumni && (
<span
style={{
fontSize: 10,
fontWeight: 600,
color: '#a78bfa',
display: 'flex',
alignItems: 'center',
gap: 3,
}}
>
<GraduationCap size={11} /> alumni
</span>
)}
{post.debt_amount && (
<span
style={{
fontSize: 10,
fontWeight: 600,
color: '#f59e0b',
display: 'flex',
alignItems: 'center',
gap: 3,
}}
>
<DollarSign size={11} /> {formatDebtAmount(post.debt_amount)}
</span>
)}
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
{timeAgo(post.posted_at)}
</span>
</div>
<h3
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-text)',
lineHeight: 1.4,
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: expanded ? 10 : 2,
WebkitBoxOrient: 'vertical',
}}
>
{post.title}
</h3>
{/* Meta row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 11, color: 'var(--color-text-muted)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<TrendingUp size={11} /> {post.score}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<MessageSquare size={11} /> {post.num_comments}
</span>
{post.author && (
<span>u/{post.author}</span>
)}
{expanded && (
<button
onClick={(e) => { e.stopPropagation(); window.open(post.url || '#', '_blank'); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color: '#34d399',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: 11,
padding: 0,
}}
>
<ExternalLink size={11} /> Open on Reddit
</button>
)}
</div>
</div>
<div style={{ flexShrink: 0 }}>
{expanded ? <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
</div>
</div>
{/* Expanded content */}
<AnimatePresence>
{expanded && post.body && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<div
style={{
marginTop: 12,
padding: '10px 12px',
backgroundColor: 'var(--color-surface-el)',
borderRadius: 8,
fontSize: 12,
color: 'var(--color-text-secondary)',
lineHeight: 1.6,
maxHeight: 200,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{post.body.slice(0, 800)}
{post.body.length > 800 && '...'}
</div>
{/* Tags: servicers + programs */}
{(post.mentions_servicer || post.mentions_program || post.school_mentioned) && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{post.mentions_servicer?.split(', ').filter(Boolean).map((s) => (
<span
key={s}
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 4,
backgroundColor: 'rgba(239, 68, 68, 0.15)',
color: '#f87171',
fontWeight: 600,
}}
>
{s}
</span>
))}
{post.mentions_program?.split(', ').filter(Boolean).map((p) => (
<span
key={p}
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 4,
backgroundColor: 'rgba(34, 197, 94, 0.15)',
color: '#4ade80',
fontWeight: 600,
}}
>
{p}
</span>
))}
{post.school_mentioned && (
<span
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 4,
backgroundColor: 'rgba(167, 139, 250, 0.15)',
color: '#a78bfa',
fontWeight: 600,
}}
>
{post.school_mentioned}
</span>
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
/* ─── Main Community Tab ─────────────────────────────────────────────────── */
export default function CommunityTab() {
const [posts, setPosts] = useState<CommunityPost[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<SortMode>('heat');
const [subreddit, setSubreddit] = useState<string>('');
const [sentiment, setSentiment] = useState<string>('');
const [searchTerm, setSearchTerm] = useState('');
const [subreddits, setSubreddits] = useState<SubredditStat[]>([]);
const [sentiments, setSentiments] = useState<SentimentStat[]>([]);
const [showFilters, setShowFilters] = useState(false);
const toast = useToast();
const debouncedSearch = useDebounce(searchTerm, 400);
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ sort, limit: '60' });
if (subreddit) params.set('subreddit', subreddit);
if (sentiment) params.set('sentiment', sentiment);
if (debouncedSearch) params.set('q', debouncedSearch);
const res = await fetch('/api/community?' + params.toString());
const data = await res.json();
setPosts(data.posts || []);
setTotal(data.total || 0);
if (data.subreddits) setSubreddits(data.subreddits);
if (data.sentiments) setSentiments(data.sentiments);
} catch {
toast.addToast('Failed to load community posts', 'error');
} finally {
setLoading(false);
}
}, [sort, subreddit, sentiment, debouncedSearch, toast]);
useEffect(() => { fetchPosts(); }, [fetchPosts]);
async function handleRefresh() {
setRefreshing(true);
toast.addToast('Scanning Reddit communities...', 'info');
try {
const res = await fetch('/api/community', { method: 'POST' });
const data = await res.json();
if (data.success) {
toast.addToast(`Imported ${data.stats?.reddit_search?.count ?? 0} search + ${data.stats?.reddit_hot?.count ?? 0} hot posts`, 'success');
await fetchPosts();
} else {
toast.addToast('Refresh failed', 'error');
}
} catch {
toast.addToast('Refresh failed', 'error');
} finally {
setRefreshing(false);
}
}
const sortOptions: { id: SortMode; label: string; icon: typeof Flame }[] = [
{ id: 'heat', label: 'Heat Score', icon: Flame },
{ id: 'date', label: 'Latest', icon: Clock },
{ id: 'score', label: 'Top Voted', icon: TrendingUp },
];
return (
<div style={{ padding: '20px 24px', maxWidth: 960, margin: '0 auto' }}>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)', marginBottom: 2 }}>
Community Pulse
</h2>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{total} posts from {subreddits.length} subreddits — sorted by {sort === 'heat' ? 'heat score' : sort === 'date' ? 'most recent' : 'top voted'}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowFilters((f) => !f)}
title="Toggle filters"
>
<Filter size={14} />
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
{refreshing ? 'Scanning...' : 'Refresh'}
</button>
</div>
</div>
{/* ── Sort + Search Bar ──────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
{/* Sort pills */}
<div style={{ display: 'flex', gap: 4, padding: 3, backgroundColor: 'var(--color-surface)', borderRadius: 8 }}>
{sortOptions.map(({ id, label, icon: SortIcon }) => (
<button
key={id}
onClick={() => setSort(id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '5px 10px',
borderRadius: 6,
border: 'none',
cursor: 'pointer',
fontSize: 11,
fontWeight: sort === id ? 700 : 500,
backgroundColor: sort === id ? 'var(--color-primary)' : 'transparent',
color: sort === id ? '#fff' : 'var(--color-text-secondary)',
transition: 'all 0.15s',
}}
>
<SortIcon size={12} /> {label}
</button>
))}
</div>
{/* Search */}
<div style={{ flex: 1, minWidth: 180, position: 'relative' }}>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--color-text-muted)',
}}
/>
<input
type="text"
className="input"
placeholder="Search posts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
aria-label="Search community posts"
style={{
width: '100%',
paddingLeft: 32,
fontSize: 12,
height: 34,
}}
/>
</div>
</div>
{/* ── Filters (collapsible) ─────────────────────────────────────── */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden', marginBottom: 12 }}
>
<div
style={{
display: 'flex',
gap: 16,
padding: 12,
backgroundColor: 'var(--color-surface)',
borderRadius: 8,
border: '1px solid var(--color-border)',
flexWrap: 'wrap',
}}
>
{/* Subreddit filter */}
<div style={{ flex: 1, minWidth: 160 }}>
<label style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
SUBREDDIT
</label>
<select
className="input"
value={subreddit}
onChange={(e) => setSubreddit(e.target.value)}
aria-label="Filter by subreddit"
style={{ width: '100%', fontSize: 12, height: 32 }}
>
<option value="">All subreddits</option>
{subreddits.map((s) => (
<option key={s.subreddit} value={s.subreddit}>
r/{s.subreddit} ({s.cnt})
</option>
))}
</select>
</div>
{/* Sentiment filter */}
<div style={{ flex: 1, minWidth: 160 }}>
<label style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
SENTIMENT
</label>
<select
className="input"
value={sentiment}
onChange={(e) => setSentiment(e.target.value)}
aria-label="Filter by sentiment"
style={{ width: '100%', fontSize: 12, height: 32 }}
>
<option value="">All sentiments</option>
{sentiments.map((s) => (
<option key={s.sentiment} value={s.sentiment}>
{s.sentiment} ({s.cnt})
</option>
))}
</select>
</div>
{/* Clear filters */}
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => { setSubreddit(''); setSentiment(''); setSearchTerm(''); }}
style={{ fontSize: 11 }}
>
Clear filters
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Stats Bar ─────────────────────────────────────────────────── */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 8,
marginBottom: 16,
}}
>
{sentiments.map((s) => (
<div
key={s.sentiment}
style={{
padding: '8px 12px',
backgroundColor: 'var(--color-surface)',
borderRadius: 8,
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: sentimentColor(s.sentiment),
flexShrink: 0,
}}
/>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)' }}>
{s.cnt}
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', textTransform: 'capitalize' }}>
{s.sentiment}
</div>
</div>
</div>
))}
<div
style={{
padding: '8px 12px',
backgroundColor: 'var(--color-surface)',
borderRadius: 8,
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Users size={14} style={{ color: '#a78bfa', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)' }}>
{subreddits.length}
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>
Subreddits
</div>
</div>
</div>
</div>
{/* ── Post List ─────────────────────────────────────────────────── */}
{loading ? (
<SkeletonList count={8} />
) : posts.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '48px 24px',
color: 'var(--color-text-muted)',
}}
>
<MessageSquare size={32} style={{ margin: '0 auto 12px', opacity: 0.4 }} />
<p style={{ fontSize: 14, fontWeight: 600 }}>No community posts found</p>
<p style={{ fontSize: 12, marginTop: 4 }}>Click Refresh to scan Reddit communities</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{/* Load more */}
{posts.length < total && (
<div style={{ textAlign: 'center', padding: 12 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
Showing {posts.length} of {total} posts
</span>
</div>
)}
</div>
)}
</div>
);
}