← back to Norma

components/donations/DonationInsights.tsx

426 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
  TrendingUp,
  Lightbulb,
  AlertTriangle,
  Trophy,
  RefreshCw,
  ChevronDown,
  ChevronUp,
  X,
  DollarSign,
} from 'lucide-react';

/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Insight {
  id: string;
  insight_type: 'trend' | 'recommendation' | 'warning' | 'milestone';
  /** Alias: some API responses may return 'type' from heuristic generator */
  type?: 'trend' | 'recommendation' | 'warning' | 'milestone';
  title: string;
  description: string;
  confidence: number;
  data_points: Record<string, unknown>;
  is_actionable: boolean;
  is_dismissed: boolean;
  created_at: string;
}

/* ─── Icon + color config by type ───────────────────────────────────────── */
const TYPE_CONFIG: Record<
  string,
  { Icon: React.ElementType; color: string; bg: string; border: string; label: string }
> = {
  trend: {
    Icon:   TrendingUp,
    color:  '#34d399',
    bg:     'rgba(52,211,153,0.1)',
    border: 'rgba(52,211,153,0.25)',
    label:  'Trend',
  },
  recommendation: {
    Icon:   Lightbulb,
    color:  '#f59e0b',
    bg:     'rgba(245,158,11,0.1)',
    border: 'rgba(245,158,11,0.25)',
    label:  'Recommendation',
  },
  warning: {
    Icon:   AlertTriangle,
    color:  '#ef4444',
    bg:     'rgba(239,68,68,0.1)',
    border: 'rgba(239,68,68,0.25)',
    label:  'Warning',
  },
  milestone: {
    Icon:   Trophy,
    color:  '#22c55e',
    bg:     'rgba(34,197,94,0.1)',
    border: 'rgba(34,197,94,0.25)',
    label:  'Milestone',
  },
};

/* ─── Format a data_points value for display ─────────────────────────────── */
function formatDataValue(val: unknown): string {
  if (val == null) return '—';
  if (typeof val === 'number') {
    if (val > 1000) return '$' + val.toLocaleString('en-US', { minimumFractionDigits: 0 });
    if (val < 1 && val > 0) return (val * 100).toFixed(0) + '%';
    return val.toLocaleString('en-US');
  }
  return String(val);
}

function humanizeKey(key: string): string {
  return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

/* ─── Individual insight card ───────────────────────────────────────────── */
function InsightCard({
  insight,
  index,
  onDismiss,
}: {
  insight: Insight;
  index: number;
  onDismiss: (id: string) => void;
}) {
  const [dismissing, setDismissing] = useState(false);
  const insightType = insight.insight_type ?? insight.type ?? 'trend';
  const cfg = TYPE_CONFIG[insightType] ?? TYPE_CONFIG.trend;
  const { Icon, color, bg, border, label } = cfg;

  const confidencePct = Math.round((insight.confidence ?? 0.8) * 100);
  const dataEntries   = Object.entries(insight.data_points ?? {}).filter(
    ([, v]) => v != null
  );

  async function handleDismiss() {
    setDismissing(true);
    try {
      // PATCH dismissal — route is /api/donations/insights/[id]
      await fetch(`/api/donations/insights/${insight.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ is_dismissed: true }),
      });
      onDismiss(insight.id);
    } catch (err) {
      console.error('[DonationInsights] dismiss error:', err);
      setDismissing(false);
    }
  }

  return (
    <motion.div
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, scale: 0.96 }}
      transition={{ duration: 0.2, delay: Math.min(index, 4) * 0.05 }}
      style={{
        backgroundColor: bg,
        border: `1px solid ${insight.is_actionable ? color : border}`,
        borderRadius: 'var(--radius-md)',
        padding: '0.75rem',
        position: 'relative',
        boxShadow: insight.is_actionable
          ? `0 0 0 1px ${color}20, 0 2px 8px rgba(0,0,0,0.15)`
          : undefined,
      }}
      aria-label={`${label} insight: ${insight.title}`}
    >
      {/* Dismiss button */}
      <button
        type="button"
        onClick={handleDismiss}
        disabled={dismissing}
        className="btn btn-ghost btn-sm"
        aria-label={`Dismiss insight: ${insight.title}`}
        style={{
          position: 'absolute', top: '0.5rem', right: '0.5rem',
          padding: '0.15rem', minWidth: 0,
        }}
      >
        {dismissing ? (
          <span className="spinner" style={{ width: 12, height: 12, borderWidth: 1.5 }} aria-hidden="true" />
        ) : (
          <X size={12} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
        )}
      </button>

      <div className="flex items-start gap-2.5 pr-6">
        {/* Icon */}
        <div
          style={{
            width: 28, height: 28, borderRadius: 6,
            backgroundColor: `${color}20`,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            flexShrink: 0,
          }}
          aria-hidden="true"
        >
          <Icon size={14} style={{ color }} />
        </div>

        <div className="flex-1 min-w-0">
          {/* Title + badge row */}
          <div className="flex items-center gap-2 flex-wrap">
            <span
              className="text-xs font-semibold"
              style={{ color: 'var(--color-text)' }}
            >
              {insight.title}
            </span>
            <span
              className="badge"
              style={{ color, backgroundColor: `${color}18`, border: `1px solid ${color}40` }}
            >
              {label}
            </span>
            {insight.is_actionable && (
              <span
                className="badge"
                style={{
                  color: '#a78bfa',
                  backgroundColor: 'rgba(167,139,250,0.12)',
                  border: '1px solid rgba(167,139,250,0.3)',
                }}
              >
                Actionable
              </span>
            )}
          </div>

          {/* Description */}
          <p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
            {insight.description}
          </p>

          {/* Data points */}
          {dataEntries.length > 0 && (
            <div className="flex flex-wrap gap-2 mt-2">
              {dataEntries.map(([key, val]) => (
                <div
                  key={key}
                  className="flex items-center gap-1 text-xs px-2 py-0.5 rounded"
                  style={{
                    backgroundColor: 'var(--color-surface)',
                    border: '1px solid var(--color-border)',
                    color: 'var(--color-text-secondary)',
                  }}
                  aria-label={`${humanizeKey(key)}: ${formatDataValue(val)}`}
                >
                  <DollarSign size={10} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
                  <span style={{ color: 'var(--color-text-muted)' }}>{humanizeKey(key)}:</span>
                  <span className="font-medium" style={{ color: 'var(--color-text)' }}>
                    {formatDataValue(val)}
                  </span>
                </div>
              ))}
            </div>
          )}

          {/* Confidence */}
          <div className="flex items-center gap-1.5 mt-2">
            <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
              Confidence:
            </span>
            <div
              style={{ width: 48, height: 3, borderRadius: 2, backgroundColor: 'var(--color-border)', overflow: 'hidden' }}
              role="meter"
              aria-valuenow={confidencePct}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-label={`Confidence: ${confidencePct}%`}
            >
              <div
                style={{
                  width: `${confidencePct}%`, height: '100%',
                  backgroundColor: color, borderRadius: 2,
                }}
              />
            </div>
            <span className="text-xs font-medium" style={{ color }}>
              {confidencePct}%
            </span>
          </div>
        </div>
      </div>
    </motion.div>
  );
}

/* ─── Main component ─────────────────────────────────────────────────────── */
export default function DonationInsights() {
  const [insights,  setInsights]  = useState<Insight[]>([]);
  const [loading,   setLoading]   = useState(true);
  const [error,     setError]     = useState<string | null>(null);
  const [collapsed, setCollapsed] = useState(false);

  const fetchInsights = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/donations/insights');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      // Filter out already-dismissed
      setInsights((data.insights ?? []).filter((i: Insight) => !i.is_dismissed));
    } catch (err) {
      setError((err as Error).message || 'Failed to load donation insights.');
    } finally {
      setLoading(false);
    }
  }, []);

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

  function handleDismiss(id: string) {
    setInsights((prev) => prev.filter((i) => i.id !== id));
  }

  const visibleInsights = insights;

  return (
    <section aria-labelledby="donation-insights-heading">
      {/* Section header */}
      <div className="flex items-center justify-between mb-3 gap-2">
        <button
          type="button"
          onClick={() => setCollapsed((v) => !v)}
          className="flex items-center gap-2 min-w-0"
          style={{
            background: 'none', border: 'none', cursor: 'pointer', padding: 0,
            color: 'var(--color-text)',
          }}
          aria-expanded={!collapsed}
          aria-controls="donation-insights-list"
        >
          <div
            style={{
              width: 28, height: 28, borderRadius: 'var(--radius-md)',
              background: 'linear-gradient(135deg, rgba(34,197,94,0.25), rgba(245,158,11,0.25))',
              border: '1px solid rgba(34,197,94,0.3)',
              display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
            }}
            aria-hidden="true"
          >
            <TrendingUp size={14} style={{ color: '#4ade80' }} />
          </div>
          <div className="text-left min-w-0">
            <h3
              id="donation-insights-heading"
              className="text-sm font-semibold"
              style={{ color: 'var(--color-text)' }}
            >
              Donation Insights
            </h3>
            <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
              {loading ? 'Loading...' : `${visibleInsights.length} insight${visibleInsights.length !== 1 ? 's' : ''}`}
            </p>
          </div>
          {collapsed
            ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
            : <ChevronUp   size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} aria-hidden="true" />
          }
        </button>

        <button
          type="button"
          onClick={fetchInsights}
          disabled={loading}
          className="btn btn-ghost btn-sm"
          aria-label="Refresh donation insights"
        >
          <RefreshCw
            size={13}
            aria-hidden="true"
            style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
          />
        </button>
      </div>

      {/* Collapsible body */}
      <AnimatePresence initial={false}>
        {!collapsed && (
          <motion.div
            key="insights-body"
            id="donation-insights-list"
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.22 }}
            style={{ overflow: 'hidden' }}
          >
            {/* Loading */}
            {loading && (
              <div className="flex items-center gap-2 py-4" aria-live="polite" aria-busy="true">
                <span className="spinner" aria-label="Loading insights" />
                <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Analyzing donation data...</p>
              </div>
            )}

            {/* Error */}
            {!loading && error && (
              <div
                role="alert"
                className="rounded-lg px-3 py-2 text-xs flex items-center justify-between gap-2"
                style={{
                  backgroundColor: 'rgba(239,68,68,0.07)',
                  border: '1px solid rgba(239,68,68,0.25)',
                  color: 'var(--color-error)',
                }}
              >
                {error}
                <button type="button" onClick={fetchInsights} className="btn btn-secondary btn-sm text-xs">
                  Retry
                </button>
              </div>
            )}

            {/* Empty */}
            {!loading && !error && visibleInsights.length === 0 && (
              <div
                className="rounded-lg flex flex-col items-center py-8 gap-2"
                style={{ backgroundColor: 'var(--color-surface)', border: '1px dashed var(--color-border)' }}
              >
                <TrendingUp size={24} style={{ color: 'rgba(34,197,94,0.4)' }} aria-hidden="true" />
                <p className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
                  No active insights
                </p>
                <p className="text-xs text-center max-w-xs" style={{ color: 'var(--color-text-muted)' }}>
                  Insights appear as you record donation activity and connect fundraising platforms.
                </p>
              </div>
            )}

            {/* Insights list */}
            {!loading && !error && visibleInsights.length > 0 && (
              <div
                className="flex flex-col gap-2"
                role="list"
                aria-label="Donation insights"
              >
                <AnimatePresence>
                  {visibleInsights.map((insight, i) => (
                    <InsightCard
                      key={insight.id}
                      insight={insight}
                      index={i}
                      onDismiss={handleDismiss}
                    />
                  ))}
                </AnimatePresence>
              </div>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </section>
  );
}