/**
* ActivityFeed Component
*
* Personalized activity feed showing parliamentary activity
* with infinite scroll and sticky filters
*
* Features:
* - Filter by activity type (All, Votes, Bills, Committees, Mentions, News, Lobbying)
* - IntersectionObserver-based infinite scroll
* - Sticky filter bar
* - Loading skeletons
* - Empty state with helpful guidance
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Vote,
FileText,
Users,
AtSign,
Bookmark,
RefreshCw,
Rss,
Building,
Loader2,
} from 'lucide-react';
import { ActivityCard, ActivityItem, ActivityType } from './ActivityCard';
import { cn } from '@canadagpt/design-system';
interface ActivityFeedProps {
locale?: string;
limit?: number;
showFilters?: boolean;
className?: string;
}
type FilterType = 'all' | ActivityType;
interface FeedResponse {
items: ActivityItem[];
hasMore: boolean;
count: number;
}
// Filter button configuration
const FILTER_TABS: {
type: FilterType;
icon: React.ElementType;
label: { en: string; fr: string };
}[] = [
{ type: 'all', icon: Rss, label: { en: 'All', fr: 'Tout' } },
{ type: 'vote', icon: Vote, label: { en: 'Votes', fr: 'Votes' } },
{ type: 'bill_update', icon: FileText, label: { en: 'Bills', fr: 'Projets de loi' } },
{ type: 'committee_meeting', icon: Users, label: { en: 'Committees', fr: 'Comites' } },
{ type: 'news_mention', icon: Rss, label: { en: 'News', fr: 'Actualites' } },
{ type: 'lobbying', icon: Building, label: { en: 'Lobbying', fr: 'Lobbying' } },
{ type: 'mention', icon: AtSign, label: { en: 'Mentions', fr: 'Mentions' } },
];
/**
* Loading skeleton for activity items
*/
function ActivitySkeleton() {
return (
<div className="animate-pulse p-4 rounded-lg bg-bg-elevated border border-border-subtle">
<div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-bg-overlay" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="h-4 w-16 bg-bg-overlay rounded-full" />
<div className="h-3 w-24 bg-bg-overlay rounded" />
</div>
<div className="h-4 w-3/4 bg-bg-overlay rounded" />
<div className="h-3 w-1/2 bg-bg-overlay rounded" />
</div>
</div>
</div>
);
}
/**
* Empty state component - shows when no activity exists (job may not have run)
*/
function EmptyState({ filter, locale }: { filter: FilterType; locale: string }) {
const isFiltered = filter !== 'all';
if (isFiltered) {
return (
<div className="text-center py-12">
<Rss className="h-12 w-12 text-text-tertiary mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-semibold text-text-primary mb-2">
{locale === 'fr' ? 'Aucune activite trouvee' : 'No activity found'}
</h3>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{locale === 'fr'
? 'Essayez de selectionner un autre filtre ou revenez plus tard.'
: 'Try selecting a different filter or check back later.'}
</p>
</div>
);
}
return (
<div className="text-center py-12">
<Rss className="h-12 w-12 text-text-tertiary mx-auto mb-4" />
<h3 className="text-lg font-semibold text-text-primary mb-2">
{locale === 'fr' ? 'Aucune activite pour le moment' : 'No activity yet'}
</h3>
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{locale === 'fr'
? "L'activite parlementaire sera bientot disponible. Revenez dans quelques heures."
: 'Parliamentary activity is being loaded. Check back soon.'}
</p>
<div className="flex items-center justify-center gap-2 text-xs text-text-tertiary">
<Bookmark size={14} />
<span>
{locale === 'fr'
? 'Ajoutez des favoris pour personnaliser ce fil'
: 'Bookmark MPs, bills, and committees to personalize this feed'}
</span>
</div>
</div>
);
}
export function ActivityFeed({
locale = 'en',
limit = 20,
showFilters = true,
className,
}: ActivityFeedProps) {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
// Ref for infinite scroll sentinel
const loadMoreRef = useRef<HTMLDivElement>(null);
/**
* Fetch activity feed
*/
const fetchFeed = useCallback(
async (reset: boolean = false) => {
const currentOffset = reset ? 0 : offset;
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
setError(null);
try {
const params = new URLSearchParams({
limit: limit.toString(),
offset: currentOffset.toString(),
});
if (activeFilter !== 'all') {
params.set('types', activeFilter);
}
const response = await fetch(`/api/feed?${params.toString()}`);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to fetch feed');
}
const data: FeedResponse = await response.json();
if (reset) {
setActivities(data.items);
setOffset(data.items.length);
} else {
setActivities((prev) => [...prev, ...data.items]);
setOffset((prev) => prev + data.items.length);
}
setHasMore(data.hasMore);
} catch (err) {
console.error('Error fetching feed:', err);
setError(err instanceof Error ? err.message : 'Failed to load feed');
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[limit, offset, activeFilter]
);
// Fetch on mount and when filter changes
useEffect(() => {
fetchFeed(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilter]);
// Infinite scroll with IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
fetchFeed(false);
}
},
{ threshold: 0.1, rootMargin: '200px' } // Preload before reaching bottom
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [hasMore, loading, loadingMore, fetchFeed]);
/**
* Handle filter change
*/
const handleFilterChange = (filter: FilterType) => {
setActiveFilter(filter);
setOffset(0);
};
/**
* Handle refresh
*/
const handleRefresh = () => {
setOffset(0);
fetchFeed(true);
};
return (
<div className={cn('space-y-0', className)}>
{/* Sticky Filter Bar */}
{showFilters && (
<div className="sticky top-16 z-10 -mx-4 px-4 py-3 bg-bg-primary/95 backdrop-blur-sm border-b border-border-subtle">
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2 overflow-x-auto scrollbar-hide flex-1 -mx-1 px-1">
{FILTER_TABS.map(({ type, icon: Icon, label }) => (
<button
key={type}
onClick={() => handleFilterChange(type)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-all',
activeFilter === type
? 'bg-accent-red text-white'
: 'bg-bg-elevated text-text-secondary hover:bg-bg-overlay hover:text-text-primary'
)}
>
<Icon size={14} />
{label[locale as 'en' | 'fr'] || label.en}
</button>
))}
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="flex-shrink-0 p-2 rounded-lg hover:bg-bg-elevated text-text-tertiary hover:text-text-primary transition-colors disabled:opacity-50"
title={locale === 'fr' ? 'Actualiser' : 'Refresh'}
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
)}
{/* Error State */}
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mt-4">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
<button
onClick={handleRefresh}
className="mt-2 text-sm text-red-600 dark:text-red-400 hover:underline"
>
{locale === 'fr' ? 'Reessayer' : 'Try again'}
</button>
</div>
)}
{/* Loading State */}
{loading && (
<div className="space-y-3 pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<ActivitySkeleton key={i} />
))}
</div>
)}
{/* Activity List */}
{!loading && !error && (
<>
{activities.length > 0 ? (
<div className="space-y-3 pt-4">
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} locale={locale} />
))}
</div>
) : (
<EmptyState filter={activeFilter} locale={locale} />
)}
{/* Infinite Scroll Sentinel */}
{hasMore && (
<div ref={loadMoreRef} className="py-8 flex justify-center">
<div
className={cn(
'flex items-center gap-2 text-text-tertiary transition-opacity',
loadingMore ? 'opacity-100' : 'opacity-0'
)}
>
<Loader2 size={20} className="animate-spin" />
<span>{locale === 'fr' ? 'Chargement...' : 'Loading more...'}</span>
</div>
</div>
)}
{/* End of feed indicator */}
{!hasMore && activities.length > 0 && (
<div className="py-8 text-center text-sm text-text-tertiary">
{locale === 'fr'
? "Vous avez tout vu! Revenez plus tard pour plus d'activité."
: "You're all caught up! Check back later for more activity."}
</div>
)}
</>
)}
</div>
);
}
export default ActivityFeed;