← back to Norma

components/donations/DonationSummary.tsx

350 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { DollarSign, RefreshCw, Users, TrendingUp, Repeat } from 'lucide-react';

/* ─── Types ──────────────────────────────────────────────────────────────── */
interface DonationRow {
  id: string;
  amount: number | string;
  source: string;
  recurring: boolean;
  donated_at: string;
}

interface SourceRow {
  source: string;
  total: number;
}

interface ApiSummary {
  total:           number;
  count:           number;
  avg:             number;
  recurring_count: number;
}

interface DonationSummaryData {
  donations: DonationRow[];
  summary:   ApiSummary;
  /** computed client-side */
  by_source: SourceRow[];
}

function computeBySource(donations: DonationRow[]): SourceRow[] {
  const map: Record<string, number> = {};
  for (const d of donations) {
    const src = d.source || 'other';
    map[src] = (map[src] ?? 0) + Number(d.amount);
  }
  return Object.entries(map)
    .map(([source, total]) => ({ source, total }))
    .sort((a, b) => b.total - a.total);
}

/* ─── Helpers ────────────────────────────────────────────────────────────── */
function fmtCurrency(n: number | string): string {
  const v = Number(n);
  if (isNaN(v)) return '$0.00';
  return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

function fmtCount(n: number | string): string {
  return Number(n).toLocaleString('en-US');
}

function currentMonthFrom(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
}

const SOURCE_COLORS: Record<string, string> = {
  actblue:       '#6366f1',
  direct:        '#22c55e',
  moveon:        '#a855f7',
  actionnetwork: '#f97316',
  check:         '#f59e0b',
  other:         '#71717a',
};

const SOURCE_LABELS: Record<string, string> = {
  actblue:       'ActBlue',
  direct:        'Direct',
  moveon:        'MoveOn',
  actionnetwork: 'Action Network',
  check:         'Check',
  other:         'Other',
};

/* ─── Stat tile ──────────────────────────────────────────────────────────── */
function StatTile({
  icon: Icon, label, value, sub, color, index,
}: {
  icon: React.ElementType;
  label: string;
  value: string;
  sub?: string;
  color: string;
  index: number;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.2, delay: index * 0.05 }}
      style={{
        backgroundColor: 'var(--color-surface-el)',
        border: '1px solid var(--color-border)',
        borderRadius: 'var(--radius-md)',
        padding: '0.625rem 0.75rem',
        flex: '1 1 0',
        minWidth: 0,
      }}
      aria-label={`${label}: ${value}`}
    >
      <div className="flex items-center gap-1.5 mb-1">
        <div
          style={{
            width: 20, height: 20, borderRadius: 5,
            backgroundColor: `${color}20`,
            display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
          }}
          aria-hidden="true"
        >
          <Icon size={11} style={{ color }} />
        </div>
        <span className="text-xs" style={{ color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
          {label}
        </span>
      </div>
      <div className="font-semibold text-sm truncate" style={{ color: 'var(--color-text)' }}>
        {value}
      </div>
      {sub && (
        <div className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
          {sub}
        </div>
      )}
    </motion.div>
  );
}

/* ─── Component ──────────────────────────────────────────────────────────── */
export default function DonationSummary() {
  const [data,    setData]    = useState<DonationSummaryData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const from = currentMonthFrom();
      const res  = await fetch(`/api/donations?from=${from}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      // Compute by_source client-side from raw donations
      const bySource = computeBySource(json.donations ?? []);
      setData({ ...json, by_source: bySource });
    } catch (err) {
      setError((err as Error).message || 'Failed to load donation summary.');
    } finally {
      setLoading(false);
    }
  }, []);

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

  const summary  = data?.summary;
  const bySrc    = data?.by_source ?? [];
  const maxTotal = Math.max(...bySrc.map((r) => Number(r.total || 0)), 1);

  const monthLabel = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });

  return (
    <section aria-labelledby="donation-summary-heading">
      <div className="flex items-center justify-between mb-3 gap-2">
        <div className="flex items-center gap-2">
          <div
            style={{
              width: 28, height: 28, borderRadius: 'var(--radius-md)',
              background: 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(34,197,94,0.25))',
              border: '1px solid rgba(16,185,129,0.3)',
              display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
            }}
            aria-hidden="true"
          >
            <DollarSign size={14} style={{ color: '#34d399' }} />
          </div>
          <div>
            <h3
              id="donation-summary-heading"
              className="text-sm font-semibold"
              style={{ color: 'var(--color-text)' }}
            >
              Donations
            </h3>
            <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
              {monthLabel}
            </p>
          </div>
        </div>

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

      {/* Loading */}
      {loading && (
        <div className="flex items-center gap-2 py-4" aria-live="polite" aria-busy="true">
          <span className="spinner" aria-label="Loading donations" />
          <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Loading donations...</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={fetchData} className="btn btn-secondary btn-sm text-xs">
            Retry
          </button>
        </div>
      )}

      {/* Content */}
      {!loading && !error && (
        <>
          {/* Stats row */}
          <div className="flex gap-2 flex-wrap mb-4">
            <StatTile
              icon={DollarSign}
              label="Total"
              value={fmtCurrency(summary?.total ?? 0)}
              color="#34d399"
              index={0}
            />
            <StatTile
              icon={Users}
              label="Donations"
              value={fmtCount(summary?.count ?? 0)}
              color="#a78bfa"
              index={1}
            />
            <StatTile
              icon={TrendingUp}
              label="Average"
              value={fmtCurrency(summary?.avg ?? 0)}
              color="#f59e0b"
              index={2}
            />
            <StatTile
              icon={Repeat}
              label="Recurring"
              value={fmtCount(summary?.recurring_count ?? 0)}
              sub="donors"
              color="#22c55e"
              index={3}
            />
          </div>

          {/* By-source mini bar chart */}
          {bySrc.length > 0 && (
            <div
              style={{
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
                borderRadius: 'var(--radius-md)',
                padding: '0.625rem 0.75rem',
              }}
              aria-label="Donations by source"
            >
              <p className="text-xs font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
                By Source
              </p>
              <div className="flex flex-col gap-1.5" role="list">
                {bySrc.map((row) => {
                  const total = Number(row.total ?? 0);
                  const pct   = Math.round((total / maxTotal) * 100);
                  const color = SOURCE_COLORS[row.source] ?? '#71717a';
                  const label = SOURCE_LABELS[row.source] ?? row.source;

                  return (
                    <div key={row.source} role="listitem" className="flex items-center gap-2">
                      <span
                        className="text-xs shrink-0"
                        style={{ color: 'var(--color-text-muted)', minWidth: 88 }}
                        aria-label={label}
                      >
                        {label}
                      </span>
                      <div
                        style={{
                          flex: 1, height: 6, borderRadius: 3,
                          backgroundColor: 'var(--color-surface)',
                          overflow: 'hidden',
                        }}
                        role="meter"
                        aria-valuenow={pct}
                        aria-valuemin={0}
                        aria-valuemax={100}
                        aria-label={`${label}: ${pct}%`}
                      >
                        <motion.div
                          initial={{ width: 0 }}
                          animate={{ width: `${pct}%` }}
                          transition={{ duration: 0.5, ease: 'easeOut' }}
                          style={{ height: '100%', backgroundColor: color, borderRadius: 3 }}
                        />
                      </div>
                      <span
                        className="text-xs shrink-0 font-medium"
                        style={{ color: 'var(--color-text-secondary)', minWidth: 64, textAlign: 'right' }}
                      >
                        {fmtCurrency(total)}
                      </span>
                    </div>
                  );
                })}
              </div>
            </div>
          )}

          {/* Empty state */}
          {bySrc.length === 0 && (
            <div
              className="rounded-lg flex flex-col items-center py-6 gap-2"
              style={{ backgroundColor: 'var(--color-surface-el)', border: '1px dashed var(--color-border)' }}
            >
              <DollarSign size={20} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
              <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
                No donations recorded this month.
              </p>
            </div>
          )}
        </>
      )}
    </section>
  );
}