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 ? (
+
+ {saving ? 'Disconnecting…' : 'Disconnect'}
+
+ ) : (
+ setConfiguring((v) => !v)}
+ >
+ {configuring ? 'Cancel' : 'Connect'}
+
+ )}
+
+ {configuring && !connected && (
+
+
+ API Key
+
+ setApiKey(e.target.value)}
+ />
+
+ {saving ? 'Connecting…' : 'Save & Connect'}
+
+
+ )}
+
+ )
+}
+
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 ? (
+
+
+
+
+ Platform
+ Mentions (7d)
+ Engagement
+ Sentiment
+ Domain Links
+ Status
+
+
+
+ {data.platforms.map((platform) => (
+
+ {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) => (
+ {
+ setPlatformFilter(p)
+ setMentionPage(0)
+ }}
+ >
+ {p}
+
+ ))}
+
+
+ {paginatedMentions.length > 0 ? (
+ <>
+
+
+
+
+ Platform
+ Author
+ Content
+ Sentiment
+ Engagement
+ Posted At
+
+
+
+
+ {paginatedMentions.map((mention) => (
+
+ {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}
+
+
+ setMentionPage((p) => p - 1)}
+ >
+ Previous
+
+ = totalPages - 1}
+ onClick={() => setMentionPage((p) => p + 1)}
+ >
+ Next
+
+
+
+ )}
+ >
+ ) : (
+
+ {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[]
+}