← 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>
  );
}