diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9ab5712..3d25b63 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { Globe, LayoutDashboard, Menu, + MessageSquare, Play, Rocket, Settings, @@ -119,6 +120,7 @@ export function RootLayout() { const path = location.pathname if (path === '/') return 'Portfolio' if (path === '/projects') return 'Projects' + if (path === '/social') return 'Social' if (path === '/runs') return 'Runs' if (path === '/settings') return 'Settings' if (path === '/setup') return 'Setup' @@ -166,6 +168,15 @@ export function RootLayout() { Projects + + + Social + Projects + + Social + Runs diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6ef6c12..9f95242 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -868,6 +868,71 @@ export function fetchAnalyticsSources(project: string, window?: MetricsWindow): return apiFetch(`/projects/${encodeURIComponent(project)}/analytics/sources${qs}`) } +// ── Social monitoring ──────────────────────────────────────────────────────── + +export interface ApiSocialPlatform { + id: string + name: string + configured: boolean + mentions7d: number + engagement: number + sentiment: number + domainLinks: number +} + +export interface ApiSocialMention { + id: string + platform: string + author: string + content: string + sentiment: 'positive' | 'negative' | 'neutral' + likes: number + shares: number + comments: number + postedAt: string + url: string + projectKeywords?: string[] +} + +export interface ApiSocialSummary { + totalMentions7d: number + sentimentScore: number + domainLinks: number + platforms: ApiSocialPlatform[] + recentMentions: ApiSocialMention[] + trendByDay?: number[] +} + +export function fetchSocialSummary(): Promise { + return apiFetch('/social/summary') +} + +export function fetchProjectSocialMentions( + project: string, + params?: { platform?: string; limit?: number; offset?: number }, +): Promise { + const qs = new URLSearchParams() + if (params?.platform) qs.set('platform', params.platform) + if (params?.limit !== undefined) qs.set('limit', String(params.limit)) + if (params?.offset !== undefined) qs.set('offset', String(params.offset)) + const query = qs.toString() ? `?${qs.toString()}` : '' + return apiFetch(`/projects/${encodeURIComponent(project)}/social/mentions${query}`) +} + +export function connectSocialPlatform(platform: string, apiKey: string): Promise { + return apiFetch(`/social/platforms/${encodeURIComponent(platform)}/connect`, { + method: 'POST', + body: JSON.stringify({ apiKey }), + }) +} + +export function disconnectSocialPlatform(platform: string): Promise { + return apiFetch(`/social/platforms/${encodeURIComponent(platform)}/disconnect`, { + method: 'POST', + body: '{}', + }) +} + // ── Health ────────────────────────────────────────────────────────────────── import type { ServiceStatus } from './view-models.js' diff --git a/apps/web/src/components/project/SocialSignalsSection.tsx b/apps/web/src/components/project/SocialSignalsSection.tsx new file mode 100644 index 0000000..ad4fa20 --- /dev/null +++ b/apps/web/src/components/project/SocialSignalsSection.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from 'react' +import { ExternalLink } from 'lucide-react' + +import { ToneBadge } from '../shared/ToneBadge.js' +import { Sparkline } from '../shared/Sparkline.js' +import { fetchProjectSocialMentions, type ApiSocialMention } from '../../api.js' +import type { MetricTone } from '../../view-models.js' + +const PLATFORM_FILTERS = ['All', 'Twitter', 'Reddit', 'LinkedIn'] + +function sentimentTone(sentiment: 'positive' | 'negative' | 'neutral'): MetricTone { + if (sentiment === 'positive') return 'positive' + if (sentiment === 'negative') return 'negative' + return 'neutral' +} + +function summarizeMentions(mentions: ApiSocialMention[]) { + const positive = mentions.filter((m) => m.sentiment === 'positive').length + const negative = mentions.filter((m) => m.sentiment === 'negative').length + const total = mentions.length + + // Find top hashtag or subreddit pattern + const tags: Record = {} + for (const m of mentions) { + const matches = m.content.match(/[#/][\w]+/g) ?? [] + for (const tag of matches) { + tags[tag] = (tags[tag] ?? 0) + 1 + } + } + const topTag = Object.entries(tags).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '—' + + return { + total, + positivePercent: total > 0 ? Math.round((positive / total) * 100) : 0, + negativePercent: total > 0 ? Math.round((negative / total) * 100) : 0, + topTag, + } +} + +function formatPostedAt(iso: string): string { + try { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + } catch { + return iso + } +} + +export function SocialSignalsSection({ projectName }: { projectName: string }) { + const [mentions, setMentions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [platformFilter, setPlatformFilter] = useState('All') + + useEffect(() => { + setLoading(true) + setError(null) + fetchProjectSocialMentions(projectName, { limit: 50 }) + .then(setMentions) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load social signals')) + .finally(() => setLoading(false)) + }, [projectName]) + + const filteredMentions = mentions.filter( + (m) => platformFilter === 'All' || m.platform === platformFilter, + ) + + const summary = summarizeMentions(filteredMentions) + + // Build a simple 7-day sparkline from postedAt timestamps + const trendByDay = (() => { + const counts: Record = {} + for (const m of mentions) { + const day = m.postedAt.slice(0, 10) + counts[day] = (counts[day] ?? 0) + 1 + } + const days = Object.keys(counts).sort() + return days.map((d) => counts[d] ?? 0) + })() + + if (loading) { + return ( +
+
+
+

Social

+

Social Signals

+
+
+

Loading social signals…

+
+ ) + } + + if (error) { + return ( +
+
+
+

Social

+

Social Signals

+
+
+

+ Social monitoring not available. Connect platforms in{' '} + + Settings → Social Platforms + + . +

+
+ ) + } + + return ( +
+
+
+

Social

+

Social Signals

+
+
+ + {/* Summary row */} +
+
+

Mentions

+

{summary.total}

+
+
+

Positive

+

{summary.positivePercent}%

+
+
+

Negative

+

{summary.negativePercent}%

+
+
+

Top tag

+

{summary.topTag}

+
+ {trendByDay.length > 1 && ( +
+ Trend + +
+ )} +
+ + {/* Platform filter chips */} +
+ {PLATFORM_FILTERS.map((p) => ( + + ))} +
+ + {filteredMentions.length > 0 ? ( +
+ + + + + + + + + + + + + + {filteredMentions.slice(0, 20).map((mention) => ( + + + + + + + + + + ))} + +
PlatformAuthorContentSentimentEngagementPosted
{mention.platform}{mention.author} + {mention.content.length > 60 + ? `${mention.content.slice(0, 60)}…` + : mention.content} + + + {mention.sentiment} + + + {mention.likes + mention.shares + mention.comments} + + {formatPostedAt(mention.postedAt)} + + {mention.url && ( + + + + )} +
+
+ ) : ( +

+ {platformFilter === 'All' + ? 'No social mentions found for this project. Connect platforms in Settings → Social Platforms.' + : `No ${platformFilter} mentions found for this project.`} +

+ )} +
+ ) +} diff --git a/apps/web/src/pages/ProjectPage.tsx b/apps/web/src/pages/ProjectPage.tsx index f0d3945..27729f4 100644 --- a/apps/web/src/pages/ProjectPage.tsx +++ b/apps/web/src/pages/ProjectPage.tsx @@ -15,6 +15,7 @@ import { EvidenceTable } from '../components/project/EvidenceTable.js' import { CompetitorTable } from '../components/project/CompetitorTable.js' import { AnalyticsSection } from '../components/project/AnalyticsSection.js' import { GscSection } from '../components/project/GscSection.js' +import { SocialSignalsSection } from '../components/project/SocialSignalsSection.js' import { formatTimestamp } from '../lib/format-helpers.js' import { ProjectSettingsSection } from '../components/project/ProjectSettingsSection.js' import { ScheduleSection } from '../components/project/ScheduleSection.js' @@ -1029,6 +1030,9 @@ export function ProjectPage({ /> + {/* Social Signals */} + + {/* Competitor table */}
diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx index 8130566..446702f 100644 --- a/apps/web/src/pages/ProjectsPage.tsx +++ b/apps/web/src/pages/ProjectsPage.tsx @@ -6,6 +6,7 @@ import { Button } from '../components/ui/button.js' import { Card } from '../components/ui/card.js' import { StatusBadge } from '../components/shared/StatusBadge.js' import { ToneBadge } from '../components/shared/ToneBadge.js' +import { Sparkline } from '../components/shared/Sparkline.js' import { YamlApplyPanel } from '../components/project/YamlApplyPanel.js' import { createProject } from '../api.js' import { useDashboard } from '../queries/use-dashboard.js' @@ -162,6 +163,7 @@ export function ProjectsPage() { Name Domain Visibility + Social (7d) Last run Country @@ -169,6 +171,7 @@ export function ProjectsPage() { {projects.map((p) => { const latestRun = p.recentRuns[0] + const socialTrend = p.socialTrend ?? [] return ( navigate({ to: '/projects/$projectId', params: { projectId: p.project.id } })}> @@ -186,6 +189,13 @@ export function ProjectsPage() { {p.visibilitySummary.value} + + {socialTrend.length > 1 ? ( + + ) : ( + + )} + {latestRun ? ( diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index fc473f8..c9b84bf 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -5,7 +5,7 @@ import { Card } from '../components/ui/card.js' import { ToneBadge } from '../components/shared/ToneBadge.js' import { ProviderConfigForm } from '../components/settings/ProviderConfigForm.js' import { GoogleOAuthConfigForm } from '../components/settings/GoogleOAuthConfigForm.js' -import { updateBingApiKey } from '../api.js' +import { updateBingApiKey, connectSocialPlatform, disconnectSocialPlatform } from '../api.js' import { CdpConfigCard } from '../components/settings/CdpConfigCard.js' import { toneFromService } from '../lib/tone-helpers.js' import { useDashboard } from '../queries/use-dashboard.js' @@ -13,6 +13,114 @@ import { useHealth } from '../queries/use-health.js' import { useInitialDashboard } from '../contexts/dashboard-context.js' import type { HealthSnapshot } from '../view-models.js' +const SOCIAL_PLATFORMS = [ + { id: 'twitter', name: 'Twitter / X', description: 'Monitor brand mentions, hashtags, and engagement.' }, + { id: 'reddit', name: 'Reddit', description: 'Track subreddit mentions and comment sentiment.' }, + { id: 'linkedin', name: 'LinkedIn', description: 'Follow professional mentions and company references.' }, +] + +function SocialPlatformCard({ + platform, +}: { + platform: (typeof SOCIAL_PLATFORMS)[number] +}) { + const [apiKey, setApiKey] = useState('') + const [connected, setConnected] = useState(false) + const [configuring, setConfiguring] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleConnect = async () => { + if (!apiKey.trim()) return + setSaving(true) + setError(null) + try { + await connectSocialPlatform(platform.id, apiKey.trim()) + setConnected(true) + setConfiguring(false) + setApiKey('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to connect') + } finally { + setSaving(false) + } + } + + const handleDisconnect = async () => { + setSaving(true) + setError(null) + try { + await disconnectSocialPlatform(platform.id) + setConnected(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to disconnect') + } finally { + setSaving(false) + } + } + + return ( + +
+
+

Social Platform

+

{platform.name}

+
+ + {connected ? 'Connected' : 'Disconnected'} + +
+

{platform.description}

+ {error &&

{error}

} +
+ {connected ? ( + + ) : ( + + )} +
+ {configuring && !connected && ( +
+ + setApiKey(e.target.value)} + /> + +
+ )} +
+ ) +} + const defaultHealthSnapshot: HealthSnapshot = { apiStatus: { label: 'API', state: 'checking', detail: 'Checking service health' }, workerStatus: { label: 'Worker', state: 'checking', detail: 'Checking service health' }, @@ -220,6 +328,10 @@ export function SettingsPage() { + {SOCIAL_PLATFORMS.map((platform) => ( + + ))} +
diff --git a/apps/web/src/pages/SocialPage.tsx b/apps/web/src/pages/SocialPage.tsx new file mode 100644 index 0000000..865d48d --- /dev/null +++ b/apps/web/src/pages/SocialPage.tsx @@ -0,0 +1,317 @@ +import { useEffect, useState } from 'react' +import { ExternalLink } from 'lucide-react' + +import { Card } from '../components/ui/card.js' +import { Button } from '../components/ui/button.js' +import { ScoreGauge } from '../components/shared/ScoreGauge.js' +import { ToneBadge } from '../components/shared/ToneBadge.js' +import { Sparkline } from '../components/shared/Sparkline.js' +import { fetchSocialSummary, type ApiSocialSummary } from '../api.js' +import type { MetricTone } from '../view-models.js' + +const PLATFORMS = ['Twitter', 'Reddit', 'LinkedIn'] + +function sentimentTone(sentiment: number): MetricTone { + if (sentiment >= 65) return 'positive' + if (sentiment >= 40) return 'neutral' + return 'negative' +} + +function mentionSentimentTone(sentiment: 'positive' | 'negative' | 'neutral'): MetricTone { + if (sentiment === 'positive') return 'positive' + if (sentiment === 'negative') return 'negative' + return 'neutral' +} + +function formatEngagement(likes: number, shares: number, comments: number): string { + const total = likes + shares + comments + if (total >= 1000) return `${(total / 1000).toFixed(1)}k` + return String(total) +} + +function formatPostedAt(iso: string): string { + try { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return iso + } +} + +export function SocialPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [platformFilter, setPlatformFilter] = useState('All') + const [mentionPage, setMentionPage] = useState(0) + + const PAGE_SIZE = 10 + + useEffect(() => { + setLoading(true) + setError(null) + fetchSocialSummary() + .then(setData) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load social data')) + .finally(() => setLoading(false)) + }, []) + + const filteredMentions = (data?.recentMentions ?? []).filter( + (m) => platformFilter === 'All' || m.platform === platformFilter, + ) + const paginatedMentions = filteredMentions.slice(mentionPage * PAGE_SIZE, (mentionPage + 1) * PAGE_SIZE) + const totalPages = Math.ceil(filteredMentions.length / PAGE_SIZE) + + const totalMentions = data?.totalMentions7d ?? 0 + const sentimentScore = data?.sentimentScore ?? 0 + const domainLinks = data?.domainLinks ?? 0 + const trendPoints = data?.trendByDay ?? [] + + return ( +
+
+
+

Social

+

Mentions, sentiment, and domain links across social platforms.

+
+
+ + {loading && ( +

+ Loading social data… +

+ )} + + {error && ( + +

+ {error} +

+

+ Social monitoring requires the social API endpoints to be configured. Check Settings → Social Platforms. +

+
+ )} + + {!loading && !error && ( + <> + {/* Hero metrics row */} +
+
+ 1 ? `${trendPoints.at(-1)! - trendPoints[0]!} since start of period` : '—'} + tone={totalMentions > 0 ? 'positive' : 'neutral'} + description="Posts and comments mentioning your brand across all connected platforms." + isNumeric={false} + /> + = 65 ? 'Mostly positive' : sentimentScore >= 40 ? 'Mixed' : 'Mostly negative'} + tone={sentimentTone(sentimentScore)} + description="Percentage of mentions with a positive sentiment signal." + isNumeric={false} + /> + 0 ? 'positive' : 'neutral'} + description="Mentions that include a direct link to your canonical domain." + isNumeric={false} + /> +
+ + {trendPoints.length > 1 && ( +
+ 7d trend + +
+ )} +
+ + {/* Platform breakdown table */} +
+
+
+

Social platforms

+

Platform breakdown

+
+
+ + {data?.platforms && data.platforms.length > 0 ? ( +
+ + + + + + + + + + + + + {data.platforms.map((platform) => ( + + + + + + + + + ))} + +
PlatformMentions (7d)EngagementSentimentDomain LinksStatus
{platform.name}{platform.mentions7d.toLocaleString()}{platform.engagement.toLocaleString()} + + {platform.sentiment}% + + {platform.domainLinks.toLocaleString()} + + {platform.configured ? 'Connected' : 'Disconnected'} + +
+
+ ) : ( +

+ No platforms connected yet. Go to Settings → Social Platforms to connect your first platform. +

+ )} +
+ + {/* Recent mentions table */} +
+
+
+

Recent activity

+

Recent mentions

+
+

{filteredMentions.length} mention{filteredMentions.length !== 1 ? 's' : ''}

+
+ + {/* Platform filter chips */} +
+ {['All', ...PLATFORMS].map((p) => ( + + ))} +
+ + {paginatedMentions.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + {paginatedMentions.map((mention) => ( + + + + + + + + + + ))} + +
PlatformAuthorContentSentimentEngagementPosted At
{mention.platform}{mention.author} + {mention.content.length > 80 + ? `${mention.content.slice(0, 80)}…` + : mention.content} + + + {mention.sentiment} + + + {formatEngagement(mention.likes, mention.shares, mention.comments)} + + {formatPostedAt(mention.postedAt)} + + {mention.url && ( + + + + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {mentionPage + 1} of {totalPages} +

+
+ + +
+
+ )} + + ) : ( +

+ {platformFilter === 'All' + ? 'No mentions found. Connect a platform in Settings → Social Platforms.' + : `No ${platformFilter} mentions found.`} +

+ )} +
+ + )} +
+ ) +} diff --git a/apps/web/src/router/routes.tsx b/apps/web/src/router/routes.tsx index e664f5f..af70048 100644 --- a/apps/web/src/router/routes.tsx +++ b/apps/web/src/router/routes.tsx @@ -9,6 +9,7 @@ import { ProjectPage } from '../pages/ProjectPage.js' import { RunsPage } from '../pages/RunsPage.js' import { SettingsPage } from '../pages/SettingsPage.js' import { SetupPage } from '../pages/SetupPage.js' +import { SocialPage } from '../pages/SocialPage.js' import { NotFoundPage } from '../pages/NotFoundPage.js' import { queryKeys } from '../queries/query-keys.js' @@ -80,6 +81,12 @@ export const projectAnalyticsRoute = createRoute({ component: () => , }) +export const socialRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/social', + component: SocialPage, +}) + export const runsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/runs', @@ -121,6 +128,7 @@ export const routeTree = rootRoute.addChildren([ projectSearchConsoleRoute, projectAnalyticsRoute, ]), + socialRoute, runsRoute, settingsRoute, setupRoute, diff --git a/apps/web/src/view-models.ts b/apps/web/src/view-models.ts index 4cf260c..f2fdbfc 100644 --- a/apps/web/src/view-models.ts +++ b/apps/web/src/view-models.ts @@ -156,6 +156,8 @@ export interface ProjectCommandCenterVm { visibilityEvidence: CitationInsightVm[] competitors: CompetitorVm[] recentRuns: RunListItemVm[] + /** 7-day social mention trend (mention counts per day, oldest first). Optional until social API is connected. */ + socialTrend?: number[] } export interface SetupHealthCheckVm { @@ -230,3 +232,54 @@ export interface DashboardVm { } export type RunFilter = 'all' | RunStatus + +// ── Social monitoring ───────────────────────────────────────────────────────── + +export interface SocialPlatformVm { + id: string + name: string + mentions7d: number + engagement: number + sentiment: number + domainLinks: number + connected: boolean +} + +export interface SocialMentionVm { + id: string + platform: string + author: string + content: string + sentiment: 'positive' | 'negative' | 'neutral' + sentimentTone: MetricTone + likes: number + shares: number + comments: number + postedAt: string + url: string +} + +export interface SocialSummaryVm { + totalMentions: number + sentimentScore: number + domainLinks: number + trendByDay: number[] +} + +export interface SocialOverviewVm { + summary: SocialSummaryVm + platforms: SocialPlatformVm[] + recentMentions: SocialMentionVm[] + mentionPage: number + mentionPageSize: number + platformFilter: string +} + +export interface ProjectSocialSignalsVm { + mentionCount: number + positivePercent: number + negativePercent: number + topTag: string + mentions: SocialMentionVm[] + trendByDay: number[] +}