/**
* 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
* - Inline advertisements (skipped for PRO users)
*/
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Vote,
FileText,
Users,
AtSign,
Bookmark,
RefreshCw,
Rss,
Building,
Loader2,
} from 'lucide-react';
import { ActivityCard, ActivityItem, ActivityType } from './ActivityCard';
import { AdvertisementCard } from './AdvertisementCard';
import { cn } from '@canadagpt/design-system';
import { AD_CONFIG, type TargetedAd } from '@/types/ads';
interface ActivityFeedProps {
locale?: string;
limit?: number;
showFilters?: boolean;
showAds?: boolean;
userSubscriptionTier?: string | null;
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 };
shortLabel?: { 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' } },
{ type: 'committee_meeting', icon: Users, label: { en: 'Committees', fr: 'Comites' }, shortLabel: { en: 'Cttees', fr: 'Ctes' } },
{ type: 'mention', icon: AtSign, label: { en: 'Mentions', fr: 'Mentions' } },
{ type: 'news_mention', icon: Rss, label: { en: 'News', fr: 'Nouvelles' } },
];
/**
* 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,
showAds = true,
userSubscriptionTier = null,
className,
}: ActivityFeedProps) {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [ads, setAds] = useState<TargetedAd[]>([]);
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);
// Check if user is in a tier that normally hides ads
const isAdFreeTier = useMemo(() => {
return userSubscriptionTier && AD_CONFIG.hideForTiers.includes(userSubscriptionTier);
}, [userSubscriptionTier]);
// Filter ads based on user tier - PRO users only see ads with show_to_all_tiers flag
const filteredAds = useMemo(() => {
if (!showAds) return [];
if (isAdFreeTier) {
return ads.filter((ad) => ad.show_to_all_tiers);
}
return ads;
}, [showAds, isAdFreeTier, ads]);
/**
* Fetch ads for display in feed
*/
const fetchAds = useCallback(async () => {
if (!showAds) return;
try {
const params = new URLSearchParams({
locale,
placement: 'feed_inline',
limit: String(AD_CONFIG.feed_inline.maxAdsPerPage),
});
const response = await fetch(`/api/ads?${params.toString()}`);
if (response.ok) {
const data = await response.json();
setAds(data.ads || []);
}
} catch (err) {
console.error('Error fetching ads:', err);
// Don't show error to user - just don't show ads
}
}, [locale, showAds]);
/**
* Track ad impression
*/
const trackAdImpression = useCallback(async (adId: string) => {
try {
await fetch('/api/ads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ad_id: adId, event_type: 'impression' }),
});
} catch (err) {
// Silently fail - tracking shouldn't break the UI
}
}, []);
/**
* Track ad click
*/
const trackAdClick = useCallback(async (adId: string) => {
try {
await fetch('/api/ads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ad_id: adId, event_type: 'click' }),
});
} catch (err) {
// Silently fail
}
}, []);
/**
* 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);
fetchAds();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilter]);
/**
* Build feed items with ads interspersed
* Ads appear after every N items (defined in AD_CONFIG.feed_inline.frequency)
* PRO users only see ads with show_to_all_tiers flag
*/
const feedItemsWithAds = useMemo(() => {
if (filteredAds.length === 0) {
return activities.map((activity) => ({ type: 'activity' as const, data: activity }));
}
const result: Array<{ type: 'activity'; data: ActivityItem } | { type: 'ad'; data: TargetedAd }> = [];
const { frequency, startAfterItem, maxAdsPerPage } = AD_CONFIG.feed_inline;
let adIndex = 0;
activities.forEach((activity, index) => {
result.push({ type: 'activity', data: activity });
// Insert ad after startAfterItem, then every frequency items
const position = index + 1;
if (
position >= startAfterItem &&
(position - startAfterItem) % frequency === 0 &&
adIndex < filteredAds.length &&
adIndex < maxAdsPerPage
) {
result.push({ type: 'ad', data: filteredAds[adIndex] });
adIndex++;
}
});
return result;
}, [activities, filteredAds]);
// 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);
// Reset scroll position to top when filter changes
window.scrollTo({ top: 0, behavior: 'smooth' });
};
/**
* 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-1.5 sm:gap-2 overflow-x-auto scrollbar-hide flex-1 -mx-1 px-1">
{FILTER_TABS.map(({ type, icon: Icon, label, shortLabel }) => (
<button
key={type}
onClick={() => handleFilterChange(type)}
className={cn(
'flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-full text-xs sm:text-sm font-medium whitespace-nowrap transition-all',
activeFilter === type
? 'bg-accent-red text-white'
: 'bg-bg-elevated text-text-secondary sm:hover:bg-bg-overlay sm:hover:text-text-primary'
)}
>
<Icon size={14} className="flex-shrink-0" />
<span className="sm:hidden">{(shortLabel || label)[locale as 'en' | 'fr'] || (shortLabel || label).en}</span>
<span className="hidden sm:inline">{label[locale as 'en' | 'fr'] || label.en}</span>
</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">
{feedItemsWithAds.map((item, index) =>
item.type === 'ad' ? (
<AdvertisementCard
key={`ad-${item.data.id}`}
ad={item.data}
locale={locale}
onImpression={trackAdImpression}
onClick={trackAdClick}
/>
) : (
<ActivityCard
key={item.data.id}
activity={item.data}
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;