/**
* ActivityCard Component
*
* Individual activity item card for the activity feed
* Displays votes, bill updates, committee activity, and user mentions
*/
'use client';
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
import {
Vote,
FileText,
Users,
AtSign,
ThumbsUp,
ThumbsDown,
ArrowRight,
Clock,
Newspaper,
ExternalLink,
Bookmark,
Sparkles,
Globe,
Zap,
TrendingUp,
Building,
} from 'lucide-react';
import { cn } from '@canadagpt/design-system';
import { BookmarkButton } from '@/components/bookmarks/BookmarkButton';
import { ShareButton } from '@/components/ShareButton';
import { ShareNewsToDiscussionButton, type NewsArticleData } from './ShareNewsToDiscussionButton';
// News source logo configuration
const NEWS_SOURCE_LOGOS: Record<string, { name: string; logo: string; bgColor: string }> = {
cbc: {
name: 'CBC News',
logo: 'https://www.cbc.ca/favicon.ico',
bgColor: 'bg-red-600',
},
globe: {
name: 'Globe & Mail',
logo: 'https://www.theglobeandmail.com/favicon.ico',
bgColor: 'bg-black',
},
national_post: {
name: 'National Post',
logo: 'https://nationalpost.com/favicon.ico',
bgColor: 'bg-red-700',
},
ctv: {
name: 'CTV News',
logo: 'https://www.ctvnews.ca/favicon.ico',
bgColor: 'bg-blue-600',
},
ipolitics: {
name: 'iPolitics',
logo: 'https://ipolitics.ca/favicon.ico',
bgColor: 'bg-slate-800',
},
legisinfo: {
name: 'LEGISinfo',
logo: 'https://www.parl.ca/favicon.ico',
bgColor: 'bg-green-800',
},
};
export type ActivityType = 'vote' | 'bill_update' | 'committee_meeting' | 'mention' | 'news_mention' | 'lobbying';
export interface ActivitySource {
type: 'bookmark' | 'suggested';
entityType: string;
entityId: string;
entityName: string | null;
}
export type FeedTier = 'parliamentary' | 'breaking' | 'personalized' | 'trending' | 'suggested' | 'discovery' | 'fallback';
export interface ActivityItem {
id: string;
activity_type: ActivityType;
entity_type: 'mp' | 'bill' | 'committee' | 'user';
entity_id: string;
title: string;
description?: string;
metadata: Record<string, any>;
occurred_at: string;
created_at?: string;
source?: ActivitySource;
relevance_score?: number;
tier?: FeedTier;
is_global?: boolean;
}
interface ActivityCardProps {
activity: ActivityItem;
locale?: string;
onBookmark?: (entityType: string, entityId: string) => void;
}
/**
* Tier badge component showing special feed tiers
*/
function TierBadge({
tier,
isGlobal,
locale,
}: {
tier?: FeedTier;
isGlobal?: boolean;
locale: string;
}) {
// Only show badges for special tiers
if (isGlobal || tier === 'parliamentary') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
<Globe size={10} />
{locale === 'fr' ? 'Parlementaire' : 'Parliamentary'}
</span>
);
}
if (tier === 'breaking') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 animate-pulse">
<Zap size={10} />
{locale === 'fr' ? 'Urgent' : 'Breaking'}
</span>
);
}
if (tier === 'trending') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
<TrendingUp size={10} />
{locale === 'fr' ? 'Tendance' : 'Trending'}
</span>
);
}
return null;
}
/**
* Source attribution component showing why an item is in the feed
*/
function SourceAttribution({
source,
locale,
onBookmark,
}: {
source: ActivitySource;
locale: string;
onBookmark?: (entityType: string, entityId: string) => void;
}) {
if (source.type === 'bookmark') {
return (
<div className="flex items-center gap-1 text-xs text-text-tertiary mt-2 pt-2 border-t border-border-subtle">
<Bookmark size={12} className="text-accent-red" />
<span>
{locale === 'fr' ? 'car vous suivez' : 'because you bookmarked'}{' '}
<Link
href={`/${locale}/${source.entityType === 'mp' ? 'mps' : source.entityType === 'bill' ? 'bills' : 'committees'}/${source.entityId}`}
className="text-brand-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{source.entityName || source.entityId}
</Link>
</span>
</div>
);
}
// Suggested item
return (
<div className="flex items-center justify-between gap-2 text-xs mt-2 pt-2 border-t border-border-subtle">
<div className="flex items-center gap-1 text-text-tertiary">
<Sparkles size={12} className="text-amber-500" />
<span className="italic">
{locale === 'fr' ? 'suggere' : 'suggested'}
{source.entityName && ` - ${source.entityName}`}
</span>
</div>
{source.entityType === 'mp' && source.entityId && (
<div onClick={(e) => e.stopPropagation()}>
<BookmarkButton
bookmarkData={{
itemType: 'mp',
itemId: source.entityId,
title: source.entityName || source.entityId,
url: `/mps/${source.entityId}`,
}}
size="sm"
showLabel={false}
/>
</div>
)}
</div>
);
}
// Icons and colors for each activity type
const ACTIVITY_CONFIG: Record<ActivityType, {
icon: React.ElementType;
bgColor: string;
textColor: string;
label: { en: string; fr: string };
}> = {
vote: {
icon: Vote,
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
textColor: 'text-orange-600 dark:text-orange-400',
label: { en: 'Vote', fr: 'Vote' },
},
bill_update: {
icon: FileText,
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
textColor: 'text-blue-600 dark:text-blue-400',
label: { en: 'Bill Update', fr: 'Mise a jour du projet de loi' },
},
committee_meeting: {
icon: Users,
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
textColor: 'text-purple-600 dark:text-purple-400',
label: { en: 'Committee', fr: 'Comite' },
},
mention: {
icon: AtSign,
bgColor: 'bg-pink-100 dark:bg-pink-900/30',
textColor: 'text-pink-600 dark:text-pink-400',
label: { en: 'Mention', fr: 'Mention' },
},
news_mention: {
icon: Newspaper,
bgColor: 'bg-emerald-100 dark:bg-emerald-900/30',
textColor: 'text-emerald-600 dark:text-emerald-400',
label: { en: 'In the News', fr: 'Dans les nouvelles' },
},
lobbying: {
icon: Building,
bgColor: 'bg-indigo-100 dark:bg-indigo-900/30',
textColor: 'text-indigo-600 dark:text-indigo-400',
label: { en: 'Lobbying', fr: 'Lobbying' },
},
};
/**
* Get the URL for an activity item
*/
function getActivityUrl(activity: ActivityItem, locale: string): string {
switch (activity.activity_type) {
case 'vote':
// metadata should contain session and vote_number
const { session, vote_number } = activity.metadata;
if (session && vote_number) {
return `/${locale}/votes/${session}/${vote_number}`;
}
return `/${locale}/votes`;
case 'bill_update':
// entity_id is bill number, metadata may have session
const billSession = activity.metadata.session || '45-1';
return `/${locale}/bills/${billSession}/${activity.entity_id.toLowerCase()}`;
case 'committee_meeting':
// entity_id is committee code, metadata may have meeting number
const committeeCode = activity.entity_id.toLowerCase();
if (activity.metadata.meeting_number) {
return `/${locale}/committees/${committeeCode}/meetings/${activity.metadata.meeting_number}`;
}
return `/${locale}/committees/${committeeCode}`;
case 'mention':
// metadata should contain post_id and context_type
const { post_id, context_type } = activity.metadata;
if (context_type === 'bill_comment' && activity.metadata.bill_number) {
return `/${locale}/bills/${activity.metadata.session || '45-1'}/${activity.metadata.bill_number.toLowerCase()}#discussion`;
}
if (post_id) {
return `/${locale}/forum/posts/${post_id}`;
}
return `/${locale}/forum`;
case 'news_mention':
// For news mentions, the URL field contains the article URL
// Return the article URL directly if available in metadata
if (activity.metadata.url) {
return activity.metadata.url as string;
}
// Otherwise link to the entity page
if (activity.entity_type === 'mp') {
return `/${locale}/mps/${activity.entity_id}`;
}
if (activity.entity_type === 'bill') {
return `/${locale}/bills/${activity.metadata.session || '45-1'}/${activity.entity_id.toLowerCase()}`;
}
if (activity.entity_type === 'committee') {
return `/${locale}/committees/${activity.entity_id.toLowerCase()}`;
}
return `/${locale}`;
case 'lobbying':
// Link to lobbying page with org filter if available
if (activity.metadata.organization_id) {
return `/${locale}/lobbying?org=${activity.metadata.organization_id}`;
}
return `/${locale}/lobbying`;
default:
return `/${locale}`;
}
}
/**
* Render vote-specific content
*/
function VoteContent({ activity, locale }: { activity: ActivityItem; locale: string }) {
const { mp_name, vote_value, bill_number, subject } = activity.metadata;
const isYea = vote_value === 'Yea' || vote_value === 'Yes';
const isNay = vote_value === 'Nay' || vote_value === 'No';
return (
<div className="flex items-center gap-2">
{isYea && <ThumbsUp size={14} className="text-green-600 dark:text-green-400" />}
{isNay && <ThumbsDown size={14} className="text-red-600 dark:text-red-400" />}
<span className="text-sm text-text-secondary">
{mp_name || activity.entity_id}{' '}
{locale === 'fr' ? 'a vote' : 'voted'}{' '}
<span className={cn(
'font-medium',
isYea && 'text-green-600 dark:text-green-400',
isNay && 'text-red-600 dark:text-red-400'
)}>
{vote_value}
</span>
{bill_number && (
<>
{' '}{locale === 'fr' ? 'sur' : 'on'}{' '}
<span className="font-medium text-text-primary">{bill_number.toUpperCase()}</span>
</>
)}
</span>
</div>
);
}
/**
* Render mention-specific content
*/
function MentionContent({ activity, locale }: { activity: ActivityItem; locale: string }) {
const { mentioner_username, post_title } = activity.metadata;
return (
<div className="text-sm text-text-secondary">
<span className="font-medium text-pink-600 dark:text-pink-400">@{mentioner_username}</span>
{' '}{locale === 'fr' ? 'vous a mentionne dans' : 'mentioned you in'}{' '}
<span className="font-medium text-text-primary">"{post_title || 'a post'}"</span>
</div>
);
}
/**
* News source logo component
*/
function NewsSourceLogo({ sourceId, size = 40 }: { sourceId: string; size?: number }) {
const sourceConfig = NEWS_SOURCE_LOGOS[sourceId];
const Icon = Newspaper;
if (!sourceConfig) {
// Fallback to generic newspaper icon
return (
<div className={cn(
'flex-shrink-0 rounded-lg flex items-center justify-center bg-emerald-100 dark:bg-emerald-900/30',
)} style={{ width: size, height: size }}>
<Icon className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
return (
<div
className={cn(
'flex-shrink-0 rounded-lg flex items-center justify-center overflow-hidden',
sourceConfig.bgColor
)}
style={{ width: size, height: size }}
>
<img
src={sourceConfig.logo}
alt={sourceConfig.name}
className="w-6 h-6 object-contain"
onError={(e) => {
// On error, replace with first letter of source name
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<span class="text-white font-bold text-lg">${sourceConfig.name.charAt(0)}</span>`;
}
}}
/>
</div>
);
}
/**
* Entity mentions display for news cards
*/
function EntityMentions({ metadata, locale }: { metadata: Record<string, any>; locale: string }) {
const mpCount = Array.isArray(metadata.mp_ids) ? metadata.mp_ids.length : 0;
const billCount = Array.isArray(metadata.bill_ids) ? metadata.bill_ids.length : 0;
const committeeCount = Array.isArray(metadata.committee_codes) ? metadata.committee_codes.length : 0;
const totalEntities = mpCount + billCount + committeeCount;
if (totalEntities === 0) return null;
return (
<div className="flex items-center gap-2 text-xs text-text-tertiary">
{mpCount > 0 && (
<span className="flex items-center gap-1">
<Users size={12} />
{mpCount}
</span>
)}
{billCount > 0 && (
<span className="flex items-center gap-1">
<FileText size={12} />
{billCount}
</span>
)}
{committeeCount > 0 && (
<span className="flex items-center gap-1">
<Users size={12} />
{committeeCount}
</span>
)}
<span className="text-text-quaternary">
{locale === 'fr' ? 'mentionnés' : 'mentioned'}
</span>
</div>
);
}
/**
* News card with side thumbnail layout - award-winning design
*/
function NewsCard({ activity, locale, url }: { activity: ActivityItem; locale: string; url: string }) {
const timeAgo = formatDistanceToNow(new Date(activity.occurred_at), { addSuffix: true });
const imageUrl = activity.metadata.image_url as string | undefined;
const sourceId = activity.metadata.source as string || '';
const sourceConfig = NEWS_SOURCE_LOGOS[sourceId];
const sourceName = sourceConfig?.name || sourceId || 'News';
const summary = activity.metadata.summary as string | undefined;
const articleUrl = activity.metadata.url as string || url;
// Bookmark data for the BookmarkButton
const bookmarkData = {
itemType: 'news' as const,
itemId: activity.id,
title: activity.title,
url: articleUrl,
imageUrl: imageUrl,
};
return (
<article className="group rounded-lg bg-bg-elevated border border-border-subtle hover:border-accent-red hover:shadow-md transition-all overflow-hidden">
<a
href={articleUrl}
target="_blank"
rel="noopener noreferrer"
className="flex"
>
{/* Thumbnail - Left side, only if exists */}
{imageUrl && (
<div className="flex-shrink-0 w-[120px] sm:w-[140px] relative overflow-hidden bg-bg-overlay">
<img
src={imageUrl}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.style.display = 'none';
}
}}
/>
</div>
)}
{/* Content - Flex grows */}
<div className="flex-1 p-4 min-w-0">
{/* Header row */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<NewsSourceLogo sourceId={sourceId} size={20} />
<span className="text-xs font-medium text-text-secondary truncate">
{sourceName}
</span>
<TierBadge tier={activity.tier} isGlobal={activity.is_global} locale={locale} />
<span className="text-xs text-text-tertiary ml-auto whitespace-nowrap flex items-center gap-1">
<Clock size={12} />
{timeAgo}
</span>
</div>
{/* Headline */}
<h3 className="text-sm font-semibold text-text-primary group-hover:text-accent-red transition-colors line-clamp-2 mb-1">
{activity.title}
</h3>
{/* Summary preview */}
{summary && (
<p className="text-xs text-text-secondary line-clamp-2 mb-2">
{summary}
</p>
)}
{/* Entity mentions */}
<EntityMentions metadata={activity.metadata} locale={locale} />
{/* Source Attribution */}
{activity.source && (
<SourceAttribution
source={activity.source}
locale={locale}
/>
)}
</div>
{/* Action buttons - Right side column */}
<div
className="flex flex-col gap-1 p-2 border-l border-border-subtle"
onClick={(e) => e.preventDefault()}
>
<BookmarkButton
bookmarkData={bookmarkData}
size="sm"
showLabel={false}
/>
<ShareButton
title={activity.title}
url={articleUrl}
size="sm"
/>
<ShareNewsToDiscussionButton
article={{
id: activity.id,
title: activity.title,
url: articleUrl,
source: sourceId,
sourceName: sourceName,
imageUrl: imageUrl,
summary: summary,
publishedAt: activity.occurred_at,
}}
size="sm"
/>
</div>
</a>
</article>
);
}
/**
* Lobbying card with organization, officials met, and subject tags
*/
function LobbyingCard({ activity, locale, url }: { activity: ActivityItem; locale: string; url: string }) {
const timeAgo = formatDistanceToNow(new Date(activity.occurred_at), { addSuffix: true });
const {
client_org_name,
registrant_name,
dpoh_names,
subject_matters,
} = activity.metadata as {
client_org_name?: string;
registrant_name?: string;
dpoh_names?: string[];
subject_matters?: string[];
};
// Bookmark data
const bookmarkData = {
itemType: 'lobbying' as const,
itemId: activity.id,
title: activity.title,
url: url,
};
return (
<article className="group rounded-lg bg-bg-elevated border border-border-subtle hover:border-accent-red hover:shadow-md transition-all">
<Link href={url} className="block p-4">
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<Building size={16} className="text-indigo-500" />
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400">
{locale === 'fr' ? 'Lobbying' : 'Lobbying'}
</span>
<TierBadge tier={activity.tier} isGlobal={activity.is_global} locale={locale} />
<span className="text-xs text-text-tertiary ml-auto flex items-center gap-1">
<Clock size={12} />
{timeAgo}
</span>
</div>
{/* Organization */}
<h3 className="font-semibold text-text-primary group-hover:text-accent-red transition-colors mb-1">
{client_org_name || activity.title}
</h3>
{/* Registrant/Lobbyist */}
{registrant_name && (
<p className="text-sm text-text-secondary mb-2">
{locale === 'fr' ? 'Lobbyiste:' : 'Lobbyist:'} {registrant_name}
</p>
)}
{/* Officials met */}
{dpoh_names && dpoh_names.length > 0 && (
<p className="text-sm text-text-secondary mb-2">
{locale === 'fr' ? 'Rencontre avec:' : 'Met with:'}{' '}
{dpoh_names.slice(0, 2).join(', ')}
{dpoh_names.length > 2 && ` +${dpoh_names.length - 2} ${locale === 'fr' ? 'autres' : 'more'}`}
</p>
)}
{/* Subject tags */}
{subject_matters && subject_matters.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{subject_matters.slice(0, 3).map((subject, i) => (
<span
key={i}
className="text-xs px-2 py-0.5 rounded bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400"
>
{subject}
</span>
))}
{subject_matters.length > 3 && (
<span className="text-xs px-2 py-0.5 rounded bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
+{subject_matters.length - 3}
</span>
)}
</div>
)}
{/* Source Attribution */}
{activity.source && (
<SourceAttribution source={activity.source} locale={locale} />
)}
</Link>
{/* Action buttons */}
<div className="flex border-t border-border-subtle">
<div className="flex-1" onClick={(e) => e.stopPropagation()}>
<BookmarkButton
bookmarkData={bookmarkData}
size="sm"
showLabel={false}
className="w-full rounded-none justify-center py-2"
/>
</div>
<div className="flex-1 border-l border-border-subtle" onClick={(e) => e.stopPropagation()}>
<ShareButton
url={url}
title={client_org_name || activity.title}
size="sm"
className="w-full rounded-none justify-center py-2"
/>
</div>
</div>
</article>
);
}
export function ActivityCard({ activity, locale = 'en', onBookmark }: ActivityCardProps) {
const config = ACTIVITY_CONFIG[activity.activity_type];
const Icon = config.icon;
const url = getActivityUrl(activity, locale);
const timeAgo = formatDistanceToNow(new Date(activity.occurred_at), { addSuffix: true });
// Use special cards for specific activity types
if (activity.activity_type === 'news_mention') {
return <NewsCard activity={activity} locale={locale} url={url} />;
}
if (activity.activity_type === 'lobbying') {
return <LobbyingCard activity={activity} locale={locale} url={url} />;
}
return (
<Link href={url}>
<div className="group p-4 rounded-lg bg-bg-elevated border border-border-subtle hover:border-accent-red hover:shadow-md transition-all cursor-pointer">
<div className="flex gap-3">
{/* Activity Type Icon */}
<div className={cn(
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
config.bgColor
)}>
<Icon className={cn('w-5 h-5', config.textColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Type Badge, Tier Badge, and Time */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={cn(
'px-2 py-0.5 text-xs font-medium rounded-full',
config.bgColor,
config.textColor
)}>
{config.label[locale as 'en' | 'fr'] || config.label.en}
</span>
<TierBadge tier={activity.tier} isGlobal={activity.is_global} locale={locale} />
<span className="flex items-center gap-1 text-xs text-text-tertiary">
<Clock size={12} />
{timeAgo}
</span>
</div>
{/* Title */}
<h4 className="text-sm font-medium text-text-primary group-hover:text-accent-red transition-colors mb-1">
{activity.title}
</h4>
{/* Activity-specific content */}
{activity.activity_type === 'vote' && (
<VoteContent activity={activity} locale={locale} />
)}
{activity.activity_type === 'mention' && (
<MentionContent activity={activity} locale={locale} />
)}
{activity.activity_type !== 'vote' && activity.activity_type !== 'mention' && activity.description && (
<p className="text-sm text-text-secondary line-clamp-2">
{activity.description}
</p>
)}
{/* Source Attribution */}
{activity.source && (
<SourceAttribution
source={activity.source}
locale={locale}
onBookmark={onBookmark}
/>
)}
</div>
{/* Arrow */}
<div className="flex-shrink-0 self-center">
<ArrowRight
size={16}
className="text-text-tertiary group-hover:text-accent-red group-hover:translate-x-0.5 transition-all"
/>
</div>
</div>
</div>
</Link>
);
}
export default ActivityCard;