Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface ApiSnapshot {
answerText: string | null
citedDomains: string[]
competitorOverlap: string[]
recommendedCompetitors: string[]
groundingSources: GroundingSource[]
searchQueries: string[]
model: string | null
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/build-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ function buildEvidenceFromTimeline(
citedDomains: snap?.citedDomains ?? [],
evidenceUrls: [],
competitorDomains: snap?.competitorOverlap ?? [],
recommendedCompetitors: snap?.recommendedCompetitors ?? [],
relatedTechnicalSignals: [],
groundingSources: snap?.groundingSources ?? [],
summary: evidenceSummary(snapState, entry.keyword),
Expand Down Expand Up @@ -448,6 +449,7 @@ function buildEvidenceFromTimeline(
citedDomains: [],
evidenceUrls: [],
competitorDomains: [],
recommendedCompetitors: [],
relatedTechnicalSignals: [],
groundingSources: [],
summary: `"${kw.keyword}" has been added but no visibility run has been triggered yet.`,
Expand Down
177 changes: 119 additions & 58 deletions apps/web/src/components/layout/EvidenceDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface EvidenceDisplayData {
answerSnippet: string
citedDomains: string[]
competitorDomains: string[]
recommendedCompetitors: string[]
groundingSources: GroundingSource[]
evidenceUrls: string[]
changeLabel: string
Expand All @@ -33,6 +34,7 @@ export function EvidenceDetailModal({
onClose: () => void
}) {
const [showFullAnswer, setShowFullAnswer] = useState(false)
const [sidebarTab, setSidebarTab] = useState<'competitors' | 'sources'>('competitors')
const [selectedRunIdx, setSelectedRunIdx] = useState(-1) // -1 = latest (current)
const [historicalSnapshot, setHistoricalSnapshot] = useState<EvidenceDisplayData | null>(null)
const [loadingHistory, setLoadingHistory] = useState(false)
Expand Down Expand Up @@ -67,6 +69,7 @@ export function EvidenceDetailModal({
answerSnippet: snap.answerText,
citedDomains: snap.citedDomains ?? evidence.citedDomains,
competitorDomains: snap.competitorOverlap ?? evidence.competitorDomains,
recommendedCompetitors: snap.recommendedCompetitors ?? evidence.recommendedCompetitors ?? [],
groundingSources: snap.groundingSources ?? evidence.groundingSources,
evidenceUrls: [],
changeLabel: evidence.changeLabel,
Expand All @@ -87,6 +90,7 @@ export function EvidenceDetailModal({
answerSnippet: evidence.answerSnippet,
citedDomains: evidence.citedDomains,
competitorDomains: evidence.competitorDomains,
recommendedCompetitors: evidence.recommendedCompetitors ?? [],
groundingSources: evidence.groundingSources,
evidenceUrls: evidence.evidenceUrls,
changeLabel: evidence.changeLabel,
Expand Down Expand Up @@ -158,6 +162,7 @@ export function EvidenceDetailModal({
answerSnippet: snap.answerText ?? '',
citedDomains: snap.citedDomains,
competitorDomains: snap.competitorOverlap,
recommendedCompetitors: snap.recommendedCompetitors ?? [],
groundingSources: snap.groundingSources,
evidenceUrls: [],
changeLabel: run.citationState,
Expand All @@ -169,6 +174,7 @@ export function EvidenceDetailModal({
answerSnippet: '',
citedDomains: [],
competitorDomains: [],
recommendedCompetitors: [],
groundingSources: [],
evidenceUrls: [],
changeLabel: run.citationState,
Expand All @@ -186,6 +192,7 @@ export function EvidenceDetailModal({
answerSnippet: '',
citedDomains: [],
competitorDomains: [],
recommendedCompetitors: [],
groundingSources: [],
evidenceUrls: [],
changeLabel: run.citationState,
Expand Down Expand Up @@ -470,72 +477,126 @@ export function EvidenceDetailModal({

{/* Right: leaderboard + sources */}
<div className="evidence-modal-sidebar">
{/* Citation leaderboard */}
{display.citedDomains.length > 0 && (
<div>
<p className="drawer-section-label">Who was cited \u2014 in order</p>
<div className="citation-leaderboard">
{display.citedDomains.map((domain, i) => {
const norm = domain.toLowerCase().replace(/^www\./, '')
const isYou = myDomains.has(norm)
const isCompetitor = !isYou && display.competitorDomains.some(
c => c.toLowerCase().replace(/^www\./, '') === norm,
)
const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other'
return (
<div key={domain} className={`citation-leaderboard-item citation-leaderboard-item--${variant}`}>
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{domain}</span>
{isYou && <span className="citation-leaderboard-tag">You</span>}
{isCompetitor && <span className="citation-leaderboard-tag">Competitor</span>}
</div>
)
})}
{!isCited && (
<div className="citation-leaderboard-item citation-leaderboard-item--not-cited border-dashed">
<span className="citation-leaderboard-rank text-zinc-600">{'\u2014'}</span>
<span className="citation-leaderboard-domain text-zinc-600">{project.project.canonicalDomain}</span>
<span className="citation-leaderboard-tag text-zinc-600">Not cited</span>
</div>
)}
</div>
{/* Tabbed sidebar navigation */}
{(display.citedDomains.length > 0 || display.recommendedCompetitors.length > 0 || display.groundingSources.length > 0 || display.evidenceUrls.length > 0) && (
<div className="sidebar-tabs">
<button
className={`sidebar-tab ${sidebarTab === 'competitors' ? 'sidebar-tab--active' : ''}`}
onClick={() => setSidebarTab('competitors')}
>
Competitors
</button>
<button
className={`sidebar-tab ${sidebarTab === 'sources' ? 'sidebar-tab--active' : ''}`}
onClick={() => setSidebarTab('sources')}
>
Sources
</button>
</div>
)}

{/* Grounding sources */}
{display.groundingSources.length > 0 && (
<div>
<p className="drawer-section-label">Grounding sources ({display.groundingSources.length})</p>
<ul className="grid gap-0.5">
{display.groundingSources.map((src, i) => (
<li key={i} className="truncate text-sm">
<a href={src.uri} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{src.title || src.uri}
</a>
</li>
))}
</ul>
</div>
{/* Competitors tab — company names (preferred) or domain fallback */}
{sidebarTab === 'competitors' && (
<>
{display.recommendedCompetitors.length > 0 ? (
<div>
<p className="drawer-section-label">AI recommended instead</p>
<div className="citation-leaderboard">
{display.recommendedCompetitors.map((name, i) => (
<div key={name} className="citation-leaderboard-item citation-leaderboard-item--competitor">
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{name}</span>
</div>
))}
{!isCited && (
<div className="citation-leaderboard-item citation-leaderboard-item--not-cited border-dashed">
<span className="citation-leaderboard-rank text-zinc-600">{'\u2014'}</span>
<span className="citation-leaderboard-domain text-zinc-600">{project.project.displayName || project.project.name}</span>
<span className="citation-leaderboard-tag text-zinc-600">Not mentioned</span>
</div>
)}
</div>
</div>
) : display.citedDomains.length > 0 ? (
<div>
<p className="drawer-section-label">Domains cited instead</p>
<div className="citation-leaderboard">
{display.citedDomains.map((domain, i) => {
const norm = domain.toLowerCase().replace(/^www\./, '')
const isYou = myDomains.has(norm)
const isCompetitor = !isYou && display.competitorDomains.some(
c => c.toLowerCase().replace(/^www\./, '') === norm,
)
const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other'
return (
<div key={domain} className={`citation-leaderboard-item citation-leaderboard-item--${variant}`}>
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{domain}</span>
{isYou && <span className="citation-leaderboard-tag">You</span>}
{isCompetitor && <span className="citation-leaderboard-tag">Competitor</span>}
</div>
)
})}
{!isCited && (
<div className="citation-leaderboard-item citation-leaderboard-item--not-cited border-dashed">
<span className="citation-leaderboard-rank text-zinc-600">{'\u2014'}</span>
<span className="citation-leaderboard-domain text-zinc-600">{project.project.canonicalDomain}</span>
<span className="citation-leaderboard-tag text-zinc-600">Not cited</span>
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No competitor data {isViewingHistory ? 'for this run' : 'yet'}
</div>
)}
</>
)}

{/* Evidence URLs */}
{display.evidenceUrls.length > 0 && (
<div>
<p className="drawer-section-label">Evidence URLs</p>
<ul className="grid gap-1">
{display.evidenceUrls.map((url) => (
<li key={url} className="truncate text-sm">
<a href={url} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{url}
</a>
</li>
))}
</ul>
</div>
{/* Sources tab — grounding sources + evidence URLs */}
{sidebarTab === 'sources' && (
<>
{display.groundingSources.length > 0 && (
<div>
<p className="drawer-section-label">Grounding sources ({display.groundingSources.length})</p>
<ul className="grid gap-0.5">
{display.groundingSources.map((src, i) => (
<li key={i} className="truncate text-sm">
<a href={src.uri} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{src.title || src.uri}
</a>
</li>
))}
</ul>
</div>
)}

{display.evidenceUrls.length > 0 && (
<div>
<p className="drawer-section-label">Evidence URLs</p>
<ul className="grid gap-1">
{display.evidenceUrls.map((url) => (
<li key={url} className="truncate text-sm">
<a href={url} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{url}
</a>
</li>
))}
</ul>
</div>
)}

{display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No source data {isViewingHistory ? 'for this run' : 'yet'}
</div>
)}
</>
)}

{/* No data state */}
{display.citedDomains.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
{display.citedDomains.length === 0 && display.recommendedCompetitors.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No citation data {isViewingHistory ? 'for this run' : 'yet'}
</div>
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const citypointEvidence: CitationInsightVm[] = [
'https://downtownsmiles.com/emergency-dentist-brooklyn',
'https://harbordental.com/same-day-emergency-care',
],
recommendedCompetitors: [],
competitorDomains: ['downtownsmiles.com', 'harbordental.com'],
groundingSources: [],
relatedTechnicalSignals: [
Expand All @@ -215,6 +216,7 @@ const citypointEvidence: CitationInsightVm[] = [
'Citypoint Dental is listed as a top emergency dentist in Brooklyn for same-day appointments and walk-in availability.',
citedDomains: ['citypointdental.com', 'downtownsmiles.com'],
evidenceUrls: ['https://citypointdental.com/emergency-dentist-brooklyn'],
recommendedCompetitors: [],
competitorDomains: ['downtownsmiles.com'],
groundingSources: [],
relatedTechnicalSignals: ['Emergency page indexed with structured data'],
Expand All @@ -233,6 +235,7 @@ const citypointEvidence: CitationInsightVm[] = [
'Based on the search results, top-rated emergency dental practices in Brooklyn include Downtown Smiles and Harbor Dental.',
citedDomains: ['downtownsmiles.com', 'harbordental.com'],
evidenceUrls: [],
recommendedCompetitors: [],
competitorDomains: ['downtownsmiles.com', 'harbordental.com'],
groundingSources: [],
relatedTechnicalSignals: ['FAQ schema missing', 'llms.txt not found'],
Expand All @@ -256,6 +259,7 @@ const citypointEvidence: CitationInsightVm[] = [
'https://citypointdental.com/invisalign-downtown-brooklyn',
'https://citypointdental.com/case-studies/invisalign-open-bite',
],
recommendedCompetitors: [],
competitorDomains: ['clearlineortho.com'],
groundingSources: [],
relatedTechnicalSignals: [
Expand All @@ -277,6 +281,7 @@ const citypointEvidence: CitationInsightVm[] = [
'Citypoint Dental in Downtown Brooklyn is highlighted for its Invisalign expertise and patient outcomes.',
citedDomains: ['citypointdental.com'],
evidenceUrls: ['https://citypointdental.com/invisalign-downtown-brooklyn'],
recommendedCompetitors: [],
competitorDomains: [],
groundingSources: [],
relatedTechnicalSignals: ['Case study pages well-indexed'],
Expand All @@ -295,6 +300,7 @@ const citypointEvidence: CitationInsightVm[] = [
'For Invisalign in Downtown Brooklyn, Clear Line Ortho and Brooklyn Smiles are frequently recommended for their experienced orthodontists.',
citedDomains: ['clearlineortho.com', 'brooklynsmiles.com'],
evidenceUrls: [],
recommendedCompetitors: [],
competitorDomains: ['clearlineortho.com'],
groundingSources: [],
relatedTechnicalSignals: ['No before/after schema on case study pages'],
Expand All @@ -318,6 +324,7 @@ const citypointEvidence: CitationInsightVm[] = [
'https://brightkidsdental.com/pediatric-dentist-brooklyn-heights',
'https://parkpediatricdental.com/insurance',
],
recommendedCompetitors: [],
competitorDomains: ['brightkidsdental.com', 'parkpediatricdental.com'],
groundingSources: [],
relatedTechnicalSignals: [
Expand All @@ -339,6 +346,7 @@ const citypointEvidence: CitationInsightVm[] = [
'For pediatric dentistry in Brooklyn Heights, Bright Kids Dental and Park Pediatric Dental are cited for family-friendly care.',
citedDomains: ['brightkidsdental.com', 'parkpediatricdental.com'],
evidenceUrls: [],
recommendedCompetitors: [],
competitorDomains: ['brightkidsdental.com', 'parkpediatricdental.com'],
groundingSources: [],
relatedTechnicalSignals: ['No dedicated pediatric page for Brooklyn Heights'],
Expand All @@ -357,6 +365,7 @@ const citypointEvidence: CitationInsightVm[] = [
'Citypoint Dental is mentioned as offering pediatric services in Brooklyn, though specialized pediatric-only practices are also highlighted.',
citedDomains: ['citypointdental.com', 'brightkidsdental.com'],
evidenceUrls: ['https://citypointdental.com/family-dentistry'],
recommendedCompetitors: [],
competitorDomains: ['brightkidsdental.com'],
groundingSources: [],
relatedTechnicalSignals: ['Family dentistry page recently updated'],
Expand Down Expand Up @@ -581,7 +590,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [
'Harbor Legal Group remains cited for borough-specific personal injury queries due to clear practice-area and case-result content.',
citedDomains: ['harborlegal.com'],
evidenceUrls: ['https://harborlegal.com/personal-injury/brooklyn'],
competitorDomains: ['shorelineinjury.com'],
recommendedCompetitors: [],
competitorDomains: ['shorelineinjury.com'],
groundingSources: [],
relatedTechnicalSignals: ['Practice-area schema intact', 'Case results link directly from service pages'],
summary: 'Grounding remains durable after the service-page consolidation.',
Expand Down Expand Up @@ -682,7 +692,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [
evidenceUrls: [
'https://northstarortho.com/locations/westchester/knee-replacement',
],
competitorDomains: ['regionaljointcare.com'],
recommendedCompetitors: [],
competitorDomains: ['regionaljointcare.com'],
groundingSources: [],
relatedTechnicalSignals: ['Physician bios now linked from treatment pages'],
summary: 'Template cleanup is helping, but proof depth still matters.',
Expand Down
Loading
Loading