← back to Norma

components/drafts/AlertsFeed.tsx

402 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import {
  Bell,
  Newspaper,
  Award,
  Megaphone,
  Vote,
  DollarSign,
  Users,
  Building2,
  ChevronDown,
  ChevronUp,
  Settings,
  AlertCircle,
} from 'lucide-react';
import { useOrg } from '@/components/OrgProvider';

/* ─── Types ────────────────────────────────────────────────────────────────── */

interface AlertItem {
  id: string;
  type: 'news' | 'grant' | 'petition' | 'congress' | 'donation' | 'community' | 'organization';
  title: string;
  summary: string;
  date: string;
  source: string;
}

interface AlertsFeedProps {
  onNavigateToSettings?: () => void;
}

/* ─── Constants ────────────────────────────────────────────────────────────── */

// NOTE: These color values are intentionally hardcoded hex strings, NOT CSS variables.
// They are used in template literal rgba() calculations (e.g. `${color}18` for the icon
// background tint), which requires a hex string — CSS variable references like
// `var(--color-warning)` cannot be concatenated in template literals to produce valid CSS.
// If a CSS variable is ever needed here, it must be resolved to its hex value first.
const TYPE_META: Record<
  AlertItem['type'],
  { icon: typeof Newspaper; color: string; label: string }
> = {
  news:         { icon: Newspaper,  color: '#06b6d4', label: 'News' },       // cyan — no matching token
  grant:        { icon: Award,      color: '#f59e0b', label: 'Grant' },       // matches --color-warning
  petition:     { icon: Megaphone,  color: '#8b5cf6', label: 'Petition' },    // purple — no token
  congress:     { icon: Vote,       color: '#6366f1', label: 'Congress' },    // matches --color-lane-b / --color-info
  donation:     { icon: DollarSign, color: '#22c55e', label: 'Donation' },    // matches --color-success (approx)
  community:    { icon: Users,      color: '#f97316', label: 'Community' },   // orange — no token
  organization: { icon: Building2,  color: '#34d399', label: 'Organization' }, // emerald tint — no token
};

const DEFAULT_CATEGORIES = 'news_media,grants,petitions,donations,community,organizations';
const VISIBLE_LIMIT = 8;
const COLLAPSED_KEY = 'alerts-feed-collapsed';

/* ─── Helpers ──────────────────────────────────────────────────────────────── */

function relativeTime(iso: string): string {
  const now = Date.now();
  const then = new Date(iso).getTime();
  if (isNaN(then)) return '';
  const diffMs = now - then;
  if (diffMs < 0) return 'just now';

  const minutes = Math.floor(diffMs / 60_000);
  if (minutes < 1) return 'just now';
  if (minutes < 60) return `${minutes}m ago`;

  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;

  const days = Math.floor(hours / 24);
  if (days === 1) return 'Yesterday';
  if (days < 7) return `${days}d ago`;

  const weeks = Math.floor(days / 7);
  if (weeks < 5) return `${weeks}w ago`;

  const months = Math.floor(days / 30);
  return `${months}mo ago`;
}

/* ─── Component ────────────────────────────────────────────────────────────── */

export default function AlertsFeed({ onNavigateToSettings }: AlertsFeedProps) {
  const [alerts, setAlerts] = useState<AlertItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);
  const [collapsed, setCollapsed] = useState(() => {
    if (typeof window === 'undefined') return false;
    return localStorage.getItem(COLLAPSED_KEY) === '1';
  });
  const [showAll, setShowAll] = useState(false);
  const { org } = useOrg();
  const orgId = org?.id;

  /* Fetch alerts — filtered by the active org's keywords */
  const fetchAlerts = useCallback(async () => {
    if (!org) return; // No org selected, don't fetch
    setLoading(true);
    setError(false);
    try {
      let categories = DEFAULT_CATEGORIES;
      const cached = typeof window !== 'undefined' ? localStorage.getItem('alert-categories') : null;
      if (cached) {
        categories = cached;
      }

      // Build keyword filter from org's search_keywords
      const kwParam = org.search_keywords?.length
        ? `&keywords=${encodeURIComponent(org.search_keywords.join(','))}`
        : '';

      const alertsPromise = fetch(`/api/alerts?categories=${categories}&limit=30${kwParam}`);

      // Refresh settings in background
      fetch('/api/settings').then(async (res) => {
        if (!res.ok) return;
        try {
          const data = await res.json();
          const prefs = data?.settings?.alert_preferences;
          if (prefs) {
            const parsed = typeof prefs === 'string' ? JSON.parse(prefs) : prefs;
            const CATEGORY_KEYS = ['news_media', 'grants', 'petitions', 'congress', 'donations', 'community', 'organizations'];
            const enabled = CATEGORY_KEYS.filter((k) => parsed[k] === true);
            if (enabled.length > 0) {
              const newCategories = enabled.join(',');
              localStorage.setItem('alert-categories', newCategories);
              if (newCategories !== categories) {
                const newRes = await fetch(`/api/alerts?categories=${newCategories}&limit=30${kwParam}`);
                if (newRes.ok) {
                  const newData = await newRes.json();
                  setAlerts(newData.alerts ?? []);
                }
              }
            }
          }
        } catch { /* ignore settings parse errors */ }
      }).catch(() => { /* ignore settings fetch errors */ });

      const res = await alertsPromise;
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      setAlerts(data.alerts ?? []);
    } catch {
      setError(true);
    } finally {
      setLoading(false);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [orgId]);

  useEffect(() => {
    fetchAlerts();
  }, [fetchAlerts]);

  /* Persist collapsed state */
  function toggleCollapsed() {
    setCollapsed((prev) => {
      const next = !prev;
      localStorage.setItem(COLLAPSED_KEY, next ? '1' : '0');
      return next;
    });
  }

  /* Don't render if error or no data after loading */
  if (!loading && error) return null;
  if (!loading && alerts.length === 0) return null;

  const visible = showAll ? alerts : alerts.slice(0, VISIBLE_LIMIT);
  const hasMore = alerts.length > VISIBLE_LIMIT;

  return (
    <div
      className="card"
      style={{
        marginBottom: 20,
        padding: 0,
        overflow: 'hidden',
      }}
    >
      {/* ── Header ──────────────────────────────────────────────────────────── */}
      <button
        type="button"
        onClick={toggleCollapsed}
        className="btn-ghost"
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
          width: '100%',
          padding: '10px 14px',
          borderRadius: 0,
          cursor: 'pointer',
          border: 'none',
          borderBottom: collapsed ? 'none' : '1px solid var(--color-border)',
        }}
      >
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <Bell size={15} style={{ color: 'var(--color-primary)' }} />
          <span
            style={{
              fontSize: '0.8125rem',
              fontWeight: 600,
              color: 'var(--color-text)',
            }}
          >
            Alerts
          </span>
          {!loading && alerts.length > 0 && (
            <span
              className="badge"
              style={{
                fontSize: '0.6875rem',
                padding: '1px 7px',
                minWidth: 20,
                textAlign: 'center',
                backgroundColor: 'var(--color-primary)',
                color: '#fff',
              }}
            >
              {alerts.length}
            </span>
          )}
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          {onNavigateToSettings && (
            <span
              role="button"
              tabIndex={0}
              onClick={(e) => {
                e.stopPropagation();
                onNavigateToSettings();
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.stopPropagation();
                  onNavigateToSettings();
                }
              }}
              style={{
                display: 'inline-flex',
                alignItems: 'center',
                gap: 4,
                fontSize: '0.6875rem',
                color: 'var(--color-text-muted)',
                cursor: 'pointer',
                padding: '2px 6px',
                borderRadius: 4,
              }}
            >
              <Settings size={12} />
              Settings
            </span>
          )}
          {collapsed ? (
            <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />
          ) : (
            <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} />
          )}
        </div>
      </button>

      {/* ── Body ────────────────────────────────────────────────────────────── */}
      {!collapsed && (
        <div style={{ padding: '6px 10px 10px' }}>
          {/* Skeleton loading */}
          {loading && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {[1, 2, 3].map((i) => (
                <div
                  key={i}
                  className="skeleton"
                  style={{ height: 36, borderRadius: 6 }}
                />
              ))}
            </div>
          )}

          {/* Alert list */}
          {!loading && visible.length > 0 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
              {visible.map((alert) => {
                const meta = TYPE_META[alert.type];
                const Icon = meta?.icon ?? AlertCircle;
                const color = meta?.color ?? 'var(--color-text-muted)';

                return (
                  <div
                    key={`${alert.type}-${alert.id}`}
                    style={{
                      display: 'flex',
                      alignItems: 'flex-start',
                      gap: 8,
                      padding: '6px 4px',
                      borderRadius: 6,
                      transition: 'background-color var(--transition)',
                      cursor: 'default',
                    }}
                    onMouseEnter={(e) => {
                      e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
                    }}
                    onMouseLeave={(e) => {
                      e.currentTarget.style.backgroundColor = 'transparent';
                    }}
                  >
                    {/* Type icon */}
                    <div
                      style={{
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        width: 24,
                        height: 24,
                        borderRadius: 6,
                        backgroundColor: `${color}18`,
                        flexShrink: 0,
                        marginTop: 1,
                      }}
                    >
                      <Icon size={13} style={{ color }} />
                    </div>

                    {/* Content */}
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div
                        style={{
                          fontSize: '0.8rem',
                          fontWeight: 500,
                          color: 'var(--color-text)',
                          lineHeight: 1.35,
                          overflow: 'hidden',
                          textOverflow: 'ellipsis',
                          whiteSpace: 'nowrap',
                        }}
                        title={alert.title}
                      >
                        {alert.title}
                      </div>
                      {alert.summary && (
                        <div
                          style={{
                            fontSize: '0.7rem',
                            color: 'var(--color-text-muted)',
                            lineHeight: 1.3,
                            overflow: 'hidden',
                            textOverflow: 'ellipsis',
                            whiteSpace: 'nowrap',
                            marginTop: 1,
                          }}
                          title={alert.summary}
                        >
                          {alert.summary}
                        </div>
                      )}
                    </div>

                    {/* Time */}
                    <span
                      style={{
                        fontSize: '0.65rem',
                        color: 'var(--color-text-muted)',
                        whiteSpace: 'nowrap',
                        flexShrink: 0,
                        marginTop: 3,
                      }}
                    >
                      {relativeTime(alert.date)}
                    </span>
                  </div>
                );
              })}
            </div>
          )}

          {/* Show more / Show less */}
          {!loading && hasMore && (
            <button
              type="button"
              onClick={() => setShowAll((p) => !p)}
              className="btn btn-ghost btn-sm"
              style={{
                width: '100%',
                marginTop: 6,
                fontSize: '0.7rem',
                justifyContent: 'center',
                color: 'var(--color-text-secondary)',
              }}
            >
              {showAll ? 'Show less' : `Show ${alerts.length - VISIBLE_LIMIT} more`}
            </button>
          )}
        </div>
      )}
    </div>
  );
}