feat: add full Reputation Blueprint demo page at /report-demo

Port the engine's report renderer (37 component files) into the marketing
site with anonymized data from a real 596-review analysis. Visitors see
the exact multi-page report paying customers receive — cover, executive
summary, rating dashboard, theme analysis, domain performance, trends,
critical issues, strengths, action plan, and tracking framework.

- Anonymized business data (Bistro El Sol, score 83.3)
- EN/ES language toggle in report toolbar
- Recharts: gauge, radar, scatter, donut, trend, heatmap charts
- Sticky CTA bar converting demo viewers to customers
- Demo nav link updated from anchor to /report-demo route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-22 21:54:47 +00:00
parent 12c3cfe2d5
commit 6a80e9fc5f
42 changed files with 6813 additions and 1 deletions

View File

@@ -0,0 +1,35 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
import ReputationBlueprint from "~/modules/marketing/demo/report/ReputationBlueprint";
import { demoSynthesis } from "~/modules/marketing/demo/demo-synthesis";
export default function DemoPage() {
const { t } = useTranslation("marketing");
return (
<div className="relative">
<ReputationBlueprint report={demoSynthesis} />
{/* Sticky CTA bar */}
<div className="bg-background/95 fixed inset-x-0 bottom-0 z-50 border-t backdrop-blur-sm">
<div className="container flex items-center justify-between px-6 py-3">
<p className="text-muted-foreground text-sm font-medium">
{t("demoPage.ctaText")}
</p>
<TurboLink
href={pathsConfig.auth.login}
className={cn(buttonVariants({ size: "sm" }))}
>
{t("demoPage.ctaButton")} &rarr;
</TurboLink>
</div>
</div>
</div>
);
}

View File

@@ -19,6 +19,7 @@ const pathsConfig = {
index: "/", index: "/",
demo: { demo: {
index: DEMO_PREFIX, index: DEMO_PREFIX,
report: "/report-demo",
scrollTest: `${DEMO_PREFIX}/scroll-test`, scrollTest: `${DEMO_PREFIX}/scroll-test`,
}, },
apps: { apps: {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
'use client';
import './styles/report-brand.css';
import { useState, useRef, useEffect } from 'react';
import type { ReportSynthesis } from './types';
import type { ReportLocale } from './i18n/useReportLocale';
import { countPages, countTrendsCharts, countTrendsPages } from './utils/paginate';
import CoverPage from './sections/CoverPage';
import TableOfContents from './sections/TableOfContents';
import HowToRead from './sections/HowToRead';
import ExecutiveSummary from './sections/ExecutiveSummary';
import RatingDashboard from './sections/RatingDashboard';
import ThemeAnalysis from './sections/ThemeAnalysis';
import DomainPerformance from './sections/DomainPerformance';
import TrendsTimeline from './sections/TrendsTimeline';
import CriticalIssues from './sections/CriticalIssues';
import StrengthsToProtect from './sections/StrengthsToProtect';
import StaffLeaderboard from './sections/StaffLeaderboard';
import ActionPlan from './sections/ActionPlan';
import TrackingFramework from './sections/TrackingFramework';
import EndPage from './sections/EndPage';
import ReviewEvidence from './sections/ReviewEvidence';
import { useReportLocale } from './i18n/useReportLocale';
interface ReputationBlueprintProps {
report: ReportSynthesis;
}
export default function ReputationBlueprint({ report }: ReputationBlueprintProps) {
const reportLang: ReportLocale = report.language === 'es' ? 'es' : 'en';
const [locale, setLocale] = useState<ReportLocale>(reportLang);
const reportRef = useRef<HTMLDivElement>(null);
const [reportReady, setReportReady] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setReportReady(true), 1500);
return () => clearTimeout(timer);
}, []);
const hasStaff = Array.isArray(report.staff_leaderboard)
? report.staff_leaderboard.length > 0
: !!(report.staff_leaderboard && (
(report.staff_leaderboard as any).individuals?.length > 0 ||
(report.staff_leaderboard as any).groups?.length > 0
));
const hasReviewEvidence = !!report.review_evidence?.length;
const hasConclusion = !!report.conclusion;
const hasTrends = countTrendsCharts(report) > 0;
const trendsPages = countTrendsPages(report);
const reviewEvidenceFiltered = (report.review_evidence || []).filter(r => r.full_text?.length > 0);
// Dynamic page counting per section
const issuePages = countPages(report.critical_issues.length, 2, 3) || 1;
const strengthPages = countPages(report.strengths.length, 2, 3) || 1;
const actionPages = countPages(report.actions.length, 3, 4) || 1;
const staffCount = hasStaff
? (Array.isArray(report.staff_leaderboard)
? report.staff_leaderboard.length
: ((report.staff_leaderboard as any)?.individuals?.length || 0) + ((report.staff_leaderboard as any)?.groups?.length || 0))
: 0;
const staffPages = hasStaff ? (countPages(staffCount, 6, 12) || 1) : 0;
const trackingPages = report.kpis.length > 0 ? 1 : 0;
const reviewEvidencePages = hasReviewEvidence ? (countPages(reviewEvidenceFiltered.length, 3, 4) || 1) : 0;
const { t } = useReportLocale(locale);
// Running page offsets
// Order: Cover → ToC → HowToRead → Exec → Rating → Theme → Domain → Trends → Issues → Strengths → Staff → Action → Tracking → EndPage → Appendix
let page = 1;
const coverStart = page; page += 1;
const tocStart = page; page += 1;
const howToReadStart = page; page += 1;
const execStart = page; page += 1;
const ratingStart = page; page += 1;
const themeStart = page; page += 1;
const domainStart = page; page += 1;
const trendsStart = page; page += trendsPages;
const issueStart = page; page += issuePages;
const strengthStart = page; page += strengthPages;
const staffStart = page; page += staffPages;
const actionStart = page; page += actionPages;
const trackingStart = page; page += trackingPages;
const endPageStart = page; page += (hasConclusion ? 1 : 0);
const reviewEvidenceStart = page; page += reviewEvidencePages;
const TOTAL_PAGES = page - 1;
// Dynamic section numbers (numbered sections start at Exec Summary = 1)
let nextSection = 5;
const trendsSectionNum = nextSection; if (hasTrends) nextSection++;
const issueSectionNum = nextSection; nextSection++;
const strengthSectionNum = nextSection; nextSection++;
const staffSectionNum = nextSection; if (hasStaff) nextSection++;
const actionSectionNum = nextSection; nextSection++;
const trackingSectionNum = nextSection; if (report.kpis.length > 0) nextSection++;
const endPageSectionNum = nextSection; if (hasConclusion) nextSection++;
// ReviewEvidence uses "A" (appendix label, not a number)
// Build ToC entries with anchor IDs for smooth-scroll navigation
const tocEntries: { number: string | number; title: string; page: number; anchorId: string; description?: string }[] = [
{ number: 1, title: t('executive_summary'), page: execStart, anchorId: 'section-exec', description: t('toc_desc_executive_summary') },
{ number: 2, title: t('rating_dashboard'), page: ratingStart, anchorId: 'section-rating', description: t('toc_desc_rating_dashboard') },
{ number: 3, title: t('theme_analysis'), page: themeStart, anchorId: 'section-themes', description: t('toc_desc_theme_analysis') },
{ number: 4, title: t('domain_performance'), page: domainStart, anchorId: 'section-domains', description: t('toc_desc_domain_performance') },
...(hasTrends ? [{ number: trendsSectionNum, title: t('trends_timeline'), page: trendsStart, anchorId: 'section-trends', description: t('toc_desc_trends_timeline') }] : []),
{ number: issueSectionNum, title: t('critical_issues'), page: issueStart, anchorId: 'section-issues', description: t('toc_desc_critical_issues') },
{ number: strengthSectionNum, title: t('protect_strengths'), page: strengthStart, anchorId: 'section-strengths', description: t('toc_desc_strengths') },
];
if (hasStaff) tocEntries.push({ number: staffSectionNum, title: t('staff_leaderboard'), page: staffStart, anchorId: 'section-staff', description: t('toc_desc_staff_leaderboard') });
tocEntries.push({ number: actionSectionNum, title: t('action_plan'), page: actionStart, anchorId: 'section-actions', description: t('toc_desc_action_plan') });
if (report.kpis.length > 0) tocEntries.push({ number: trackingSectionNum, title: t('tracking_framework'), page: trackingStart, anchorId: 'section-tracking', description: t('toc_desc_tracking_framework') });
if (hasReviewEvidence) tocEntries.push({ number: 'A', title: t('appendix_review_evidence'), page: reviewEvidenceStart, anchorId: 'section-evidence', description: t('toc_desc_review_evidence') });
return (
<div data-report-ready={reportReady ? "true" : undefined} style={{ minHeight: '100vh', background: '#E2E8F0' }}>
{/* Top Bar */}
<div className="report-toolbar" style={{
position: 'sticky', top: 0, zIndex: 50,
background: 'white', borderBottom: '1px solid var(--border-light)',
padding: '12px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between'
}}>
<div>
<h1 style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>{report.business_name}</h1>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
Reputation Blueprint &middot; {report.report_date}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Language Selector */}
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-light)' }}>
<button
onClick={() => setLocale('en')}
style={{
padding: '6px 14px', fontSize: 13, fontWeight: 500, border: 'none', cursor: 'pointer',
background: locale === 'en' ? 'var(--ui-primary)' : 'white',
color: locale === 'en' ? 'white' : 'var(--text-secondary)',
}}
>
EN
</button>
<button
onClick={() => setLocale('es')}
style={{
padding: '6px 14px', fontSize: 13, fontWeight: 500, border: 'none', cursor: 'pointer',
borderLeft: '1px solid var(--border-light)',
background: locale === 'es' ? 'var(--ui-primary)' : 'white',
color: locale === 'es' ? 'white' : 'var(--text-secondary)',
}}
>
ES
</button>
</div>
</div>
</div>
{/* Report Content */}
<div ref={reportRef} style={{ maxWidth: 1024, margin: '24px auto', paddingBottom: 48 }}>
<CoverPage report={report} locale={locale} pageNumber={coverStart} totalPages={TOTAL_PAGES} />
<TableOfContents entries={tocEntries} locale={locale} pageNumber={tocStart} totalPages={TOTAL_PAGES} />
<HowToRead locale={locale} pageNumber={howToReadStart} totalPages={TOTAL_PAGES} />
<div id="section-exec"><ExecutiveSummary report={report} locale={locale} pageNumber={execStart} totalPages={TOTAL_PAGES} /></div>
<div id="section-rating"><RatingDashboard report={report} locale={locale} pageNumber={ratingStart} totalPages={TOTAL_PAGES} /></div>
<div id="section-themes"><ThemeAnalysis report={report} locale={locale} pageNumber={themeStart} totalPages={TOTAL_PAGES} /></div>
<div id="section-domains"><DomainPerformance report={report} locale={locale} pageNumber={domainStart} totalPages={TOTAL_PAGES} /></div>
{hasTrends && <div id="section-trends"><TrendsTimeline report={report} locale={locale} startPage={trendsStart} totalPages={TOTAL_PAGES} sectionNumber={trendsSectionNum} /></div>}
<div id="section-issues"><CriticalIssues report={report} locale={locale} startPage={issueStart} totalPages={TOTAL_PAGES} sectionNumber={issueSectionNum} /></div>
<div id="section-strengths"><StrengthsToProtect report={report} locale={locale} startPage={strengthStart} totalPages={TOTAL_PAGES} sectionNumber={strengthSectionNum} /></div>
{hasStaff && <div id="section-staff"><StaffLeaderboard report={report} locale={locale} startPage={staffStart} totalPages={TOTAL_PAGES} sectionNumber={staffSectionNum} /></div>}
<div id="section-actions"><ActionPlan report={report} locale={locale} startPage={actionStart} totalPages={TOTAL_PAGES} sectionNumber={actionSectionNum} /></div>
<div id="section-tracking"><TrackingFramework report={report} locale={locale} pageNumber={trackingStart} totalPages={TOTAL_PAGES} sectionNumber={trackingSectionNum} /></div>
{hasConclusion && <EndPage report={report} locale={locale} pageNumber={endPageStart} totalPages={TOTAL_PAGES} />}
{hasReviewEvidence && <div id="section-evidence"><ReviewEvidence report={report} locale={locale} startPage={reviewEvidenceStart} totalPages={TOTAL_PAGES} sectionNumber={'A'} /></div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import type { DomainScore } from '../types';
import { translateDomain } from '../i18n/contentTranslations';
interface DomainRadarProps {
domains: DomainScore[];
locale?: string;
}
export default function DomainRadar({ domains, locale = 'en' }: DomainRadarProps) {
if (domains.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No domain data
</div>
);
}
const data = domains.map(d => ({
domain: translateDomain(d.label, locale),
score: d.score,
fullMark: 100,
}));
return (
<ResponsiveContainer width="100%" height={300}>
<RadarChart data={data} cx="50%" cy="50%" outerRadius="75%">
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="domain"
tick={{ fill: '#374151', fontSize: 12 }}
/>
<PolarRadiusAxis
domain={[0, 100]}
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickCount={6}
/>
<Radar
dataKey="score"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.2}
strokeWidth={2}
/>
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
formatter={(value: any) => [`${value ?? 0}%`, 'Score']}
/>
</RadarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import type { QuarterlyDomainSentimentPoint } from '../types';
const DOMAIN_CONFIG: Record<string, { color: string; name: string }> = {
O: { color: '#3b82f6', name: 'Output' },
P: { color: '#22c55e', name: 'People' },
J: { color: '#f59e0b', name: 'Journey' },
E: { color: '#8b5cf6', name: 'Environment' },
V: { color: '#f43f5e', name: 'Value' },
};
interface DomainSentimentTrendProps {
data: QuarterlyDomainSentimentPoint[];
}
export default function DomainSentimentTrend({ data }: DomainSentimentTrendProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No domain sentiment data
</div>
);
}
// Detect which domains have data
const activeDomains = (Object.keys(DOMAIN_CONFIG) as Array<keyof typeof DOMAIN_CONFIG>).filter(
d => data.some(p => (p as any)[d] !== undefined && (p as any)[d] !== null)
);
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="quarter"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
label={{ value: 'Positive %', angle: -90, position: 'insideLeft', style: { fill: '#6b7280', fontSize: 11 } }}
/>
{activeDomains.map(d => (
<Line
key={d}
type="monotone"
dataKey={d}
stroke={DOMAIN_CONFIG[d]?.color}
strokeWidth={2}
dot={{ fill: DOMAIN_CONFIG[d]?.color, r: 3, strokeWidth: 1, stroke: '#fff' }}
name={DOMAIN_CONFIG[d]?.name}
connectNulls
/>
))}
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => [`${Number(value).toFixed(1)}%`, name ?? '']}
/>
<Legend
verticalAlign="bottom"
iconType="line"
wrapperStyle={{ fontSize: 11, paddingTop: 8 }}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import type { ThemeScore } from '../types';
interface IntensityHeatmapProps {
themes: ThemeScore[];
}
function getCellColor(value: number, max: number): string {
if (max === 0) return '#f3f4f6';
const ratio = value / max;
if (ratio > 0.7) return '#ef4444';
if (ratio > 0.4) return '#f97316';
if (ratio > 0.15) return '#f59e0b';
if (ratio > 0) return '#fef3c7';
return '#f3f4f6';
}
function getCellTextColor(value: number, max: number): string {
if (max === 0) return '#9ca3af';
const ratio = value / max;
return ratio > 0.4 ? '#ffffff' : '#374151';
}
export default function IntensityHeatmap({ themes }: IntensityHeatmapProps) {
if (themes.length === 0) {
return (
<div className="flex items-center justify-center h-48 text-gray-400">
No intensity data
</div>
);
}
const displayed = themes.slice(0, 12);
const allValues = displayed.flatMap(t => [t.intensity.i1, t.intensity.i2, t.intensity.i3]);
const maxValue = Math.max(...allValues, 1);
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500">
<th className="text-left py-2 pr-4 font-medium">Primitive</th>
<th className="text-center py-2 px-3 font-medium">I1 (Low)</th>
<th className="text-center py-2 px-3 font-medium">I2 (Med)</th>
<th className="text-center py-2 px-3 font-medium">I3 (High)</th>
<th className="text-right py-2 pl-3 font-medium">Total</th>
</tr>
</thead>
<tbody>
{displayed.map((t) => (
<tr key={t.primitive} className="border-t border-gray-100">
<td className="py-2 pr-4 text-gray-700 font-medium truncate max-w-[160px]" title={t.label}>
{t.label}
</td>
{[t.intensity.i1, t.intensity.i2, t.intensity.i3].map((val, i) => (
<td key={i} className="text-center py-2 px-3">
<span
className="inline-block rounded-md px-3 py-1 text-xs font-semibold min-w-[40px]"
style={{
backgroundColor: getCellColor(val, maxValue),
color: getCellTextColor(val, maxValue),
}}
>
{val}
</span>
</td>
))}
<td className="text-right py-2 pl-3 text-gray-600 font-semibold">
{t.count}
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-end gap-2 mt-3">
<span className="text-xs text-gray-400">Low</span>
<div className="flex gap-0.5">
{['#fef3c7', '#f59e0b', '#f97316', '#ef4444'].map((c, i) => (
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<span className="text-xs text-gray-400">High</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface MomentumDataPoint {
period: string;
positive: number;
negative: number;
}
interface MomentumDualProps {
data: MomentumDataPoint[];
}
export default function MomentumDual({ data }: MomentumDualProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No momentum data
</div>
);
}
return (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="gradPositive" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gradNegative" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="period"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
/>
<Legend verticalAlign="bottom" height={36} />
<Area
type="monotone"
dataKey="positive"
name="Positive"
stroke="#22c55e"
fill="url(#gradPositive)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="negative"
name="Negative"
stroke="#ef4444"
fill="url(#gradNegative)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import type { QuarterlyRatingPoint } from '../types';
interface QuarterlyRatingChartProps {
data: QuarterlyRatingPoint[];
}
export default function QuarterlyRatingChart({ data }: QuarterlyRatingChartProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No quarterly data
</div>
);
}
const chartData = data.map(p => ({
quarter: p.quarter,
avg_rating: p.avg_rating,
review_count: p.review_count,
}));
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="quarter"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={4} stroke="#22c55e" strokeDasharray="5 5" strokeOpacity={0.5} />
<Line
type="monotone"
dataKey="avg_rating"
stroke="#3b82f6"
strokeWidth={2.5}
dot={(props: any) => {
const { cx, cy, payload } = props;
const r = Math.max(3, Math.min(8, Math.sqrt(payload.review_count) * 1.5));
return <circle key={`dot-${props.index}`} cx={cx} cy={cy} r={r} fill="#3b82f6" stroke="#fff" strokeWidth={2} />;
}}
name="Avg Rating"
/>
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
formatter={(value: any) => [`${Number(value).toFixed(2)} / 5`, 'Rating']}
labelFormatter={(label: string) => label}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import type { RatingTrendPoint } from '../types';
interface RatingTrendProps {
data: RatingTrendPoint[];
projection?: RatingTrendPoint[];
}
export default function RatingTrend({ data, projection }: RatingTrendProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No trend data
</div>
);
}
const chartData = data.map(p => ({
period: p.period,
rating: p.avg_rating,
reviews: p.review_count,
}));
if (projection) {
for (const p of projection) {
chartData.push({
period: p.period,
rating: undefined as unknown as number,
reviews: p.review_count,
projected: p.avg_rating,
} as typeof chartData[number] & { projected?: number });
}
}
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="period"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={4} stroke="#22c55e" strokeDasharray="5 5" strokeOpacity={0.5} />
<Line
type="monotone"
dataKey="rating"
stroke="#3b82f6"
strokeWidth={2.5}
dot={{ fill: '#3b82f6', r: 4, strokeWidth: 2, stroke: '#fff' }}
name="Avg Rating"
connectNulls={false}
/>
{projection && (
<Line
type="monotone"
dataKey="projected"
stroke="#3b82f6"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
name="Projected"
/>
)}
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
if (value === undefined || value === null) return ['-', name];
return [`${Number(value).toFixed(2)} / 5`, name === 'Projected' ? 'Projected' : 'Rating'];
}}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { PieChart, Pie, Cell } from 'recharts';
import { getScoreColor, getScoreLabel } from '../styles/report-theme';
import { useReportLocale, ReportLocale } from '../i18n/useReportLocale';
interface ReputationScoreGaugeProps {
score: number;
label?: string;
locale?: string;
}
export default function ReputationScoreGauge({ score, label, locale = 'en' }: ReputationScoreGaugeProps) {
const clampedScore = Math.max(0, Math.min(100, score));
const color = getScoreColor(clampedScore);
const bandLabel = label || getScoreLabel(clampedScore, locale);
const { t } = useReportLocale(locale as ReportLocale);
const data = [
{ value: clampedScore },
{ value: 100 - clampedScore },
];
return (
<div className="flex flex-col items-center">
<div className="relative w-48 h-48">
<PieChart width={192} height={192}>
<Pie
data={data}
cx={91}
cy={91}
startAngle={225}
endAngle={-45}
innerRadius={65}
outerRadius={85}
dataKey="value"
stroke="none"
>
<Cell fill={color} />
<Cell fill="#e5e7eb" />
</Pie>
</PieChart>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-5xl font-bold" style={{ color }}>{clampedScore}</span>
<span style={{ fontSize: 11, color: '#9ca3af', letterSpacing: 0.5, marginTop: 1 }}>{t('out_of_100')}</span>
<span className="text-sm text-gray-500 mt-1">{bandLabel}</span>
</div>
</div>
{/* 5-band scale bar */}
<div style={{ width: 180, marginTop: 8 }}>
<div style={{ display: 'flex', height: 6, borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: '40%', backgroundColor: '#ef4444' }} />
<div style={{ width: '20%', backgroundColor: '#f97316' }} />
<div style={{ width: '15%', backgroundColor: '#f59e0b' }} />
<div style={{ width: '15%', backgroundColor: '#22c55e' }} />
<div style={{ width: '10%', backgroundColor: '#059669' }} />
</div>
{/* Score position indicator */}
<div style={{ position: 'relative', height: 8 }}>
<div style={{
position: 'absolute',
left: `${clampedScore}%`,
transform: 'translateX(-50%)',
width: 0, height: 0,
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderBottom: `6px solid ${color}`,
}} />
</div>
{/* Band labels */}
<div style={{ display: 'flex', fontSize: 8, color: '#9ca3af', marginTop: 1 }}>
{(() => {
const labels = locale === 'es'
? ['Crít.', 'Malo', 'Reg.', 'Bueno', 'Exc.']
: ['Critical', 'Poor', 'Fair', 'Good', 'Exc.'];
const widths = ['40%', '20%', '15%', '15%', '10%'];
return labels.map((lbl, i) => (
<span key={i} style={{ width: widths[i], textAlign: 'center' }}>{lbl}</span>
));
})()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
ReferenceLine, LabelList,
} from 'recharts';
import type { SeasonalPatternPoint } from '../types';
interface SeasonalPatternChartProps {
data: SeasonalPatternPoint[];
}
export default function SeasonalPatternChart({ data }: SeasonalPatternChartProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No seasonal data
</div>
);
}
const totalReviews = data.reduce((sum, d) => sum + d.review_count, 0);
const overallAvg = totalReviews > 0
? data.reduce((sum, d) => sum + d.avg_rating * d.review_count, 0) / totalReviews
: 0;
return (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="quarter_label"
tick={{ fill: '#6b7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
{overallAvg > 0 && (
<ReferenceLine
y={Number(overallAvg.toFixed(2))}
stroke="#94a3b8"
strokeDasharray="4 4"
label={{ value: `Avg ${overallAvg.toFixed(2)}`, position: 'right', fill: '#94a3b8', fontSize: 10 }}
/>
)}
<Bar dataKey="avg_rating" fill="#3b82f6" radius={[4, 4, 0, 0]} maxBarSize={60}>
<LabelList dataKey="avg_rating" position="top" formatter={(v: any) => Number(v).toFixed(2)} style={{ fill: '#374151', fontSize: 11, fontWeight: 500 }} />
</Bar>
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
if (name === 'avg_rating') return [`${Number(value).toFixed(2)} / 5`, 'Rating'];
return [value, name];
}}
labelFormatter={(label: any) => String(label)}
/>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { PieChart, Pie, Cell, Tooltip } from 'recharts';
import { getValenceColor } from '../styles/report-theme';
interface SentimentDonutProps {
positive: number;
negative: number;
neutral: number;
mixed: number;
}
export default function SentimentDonut({ positive, negative, neutral, mixed }: SentimentDonutProps) {
const data = [
{ name: 'Positive', value: positive, color: getValenceColor('positive') },
{ name: 'Negative', value: negative, color: getValenceColor('negative') },
{ name: 'Neutral', value: neutral, color: getValenceColor('neutral') },
{ name: 'Mixed', value: mixed, color: getValenceColor('mixed') },
].filter(d => d.value > 0);
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No sentiment data
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flexShrink: 0 }}>
<PieChart width={200} height={200}>
<Pie
data={data}
cx={100}
cy={100}
innerRadius={55}
outerRadius={85}
dataKey="value"
nameKey="name"
stroke="white"
strokeWidth={2}
>
{data.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: any) => [(value ?? 0).toLocaleString(), 'Mentions']}
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }}
/>
</PieChart>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((entry) => (
<div key={entry.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary, #1f2937)' }}>
{entry.name}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary, #6b7280)' }}>
{((entry.value / total) * 100).toFixed(0)}% &middot; {entry.value}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useRef, useEffect } from 'react';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ZAxis,
Cell,
LabelList,
ReferenceLine,
ReferenceArea,
} from 'recharts';
import type { ThemeScore } from '../types';
import { getDomainColor } from '../styles/report-theme';
import { translateThemeLabel } from '../i18n/contentTranslations';
interface ThemeMatrixProps {
themes: ThemeScore[];
locale?: string;
}
interface DataPoint {
x: number;
y: number;
z: number;
name: string;
domain: string;
color: string;
}
interface PlacedRect {
x: number;
y: number;
w: number;
h: number;
}
const CHART_H = 320;
const CHART_MARGIN = { top: 30, right: 35, left: 15, bottom: 10 };
export default function ThemeMatrix({ themes, locale = 'en' }: ThemeMatrixProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartWidthRef = useRef(800);
// Set SVG overflow: visible so edge labels aren't clipped, and measure width
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector('svg');
if (svg) svg.setAttribute('overflow', 'visible');
chartWidthRef.current = el.clientWidth;
});
if (themes.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
No theme data
</div>
);
}
// Sort by importance (z = weight × count) so larger bubbles get label priority
const data: DataPoint[] = themes
.map(t => {
const total = t.valence.positive + t.valence.negative + t.valence.neutral + t.valence.mixed;
const sentimentRatio = total > 0 ? (t.valence.positive - t.valence.negative) / total * 100 : 0;
return {
x: t.count,
y: Math.round(sentimentRatio),
z: t.weight * t.count,
name: translateThemeLabel(t.label, locale),
domain: t.domain,
color: getDomainColor(t.domain),
};
})
.sort((a, b) => b.z - a.z);
const axisLabels = locale === 'es'
? { frequency: 'Frecuencia', sentiment: 'Sentimiento Neto %' }
: { frequency: 'Frequency', sentiment: 'Net Sentiment %' };
// Bubble radius estimator (matches ZAxis range [100, 600])
const zValues = data.map(d => d.z);
const zExtent = [Math.min(...zValues), Math.max(...zValues)] as const;
const estimateR = (z: number) => {
const area = 100 + ((z - zExtent[0]) / (zExtent[1] - zExtent[0] || 1)) * 500;
return Math.sqrt(area / Math.PI);
};
// Greedy collision-avoidance label placement.
// Mutable array reset each render — tracks placed label bounding boxes.
const placedRects: PlacedRect[] = [];
const CHAR_W = 5.5;
const LABEL_H = 13;
const GAP = 5;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderLabel = (props: any) => {
const { x, y, value, index } = props;
if (index === undefined || !value) return null;
const point = data[index]!;
const r = estimateR(point.z);
const labelW = value.length * CHAR_W;
// 4 candidate positions: above, right, left, below
const candidates = [
{ ox: 0, oy: -(r + GAP), anchor: 'middle' as const },
{ ox: r + GAP + 2, oy: 3, anchor: 'start' as const },
{ ox: -(r + GAP + 2), oy: 3, anchor: 'end' as const },
{ ox: 0, oy: r + GAP + LABEL_H, anchor: 'middle' as const },
];
let best = candidates[0]!;
let bestOverlap = Infinity;
for (const cand of candidates) {
const lx = cand.anchor === 'middle' ? (x + cand.ox - labelW / 2)
: cand.anchor === 'start' ? (x + cand.ox)
: (x + cand.ox - labelW);
const ly = (y + cand.oy) - LABEL_H;
let overlap = 0;
// Boundary penalty — heavily penalize positions outside the chart area
if (ly < 2 || (ly + LABEL_H) > CHART_H - 2) overlap += 50000;
if (lx < 2 || (lx + labelW) > chartWidthRef.current - 2) overlap += 50000;
for (const rect of placedRects) {
const ox = Math.max(0, Math.min(lx + labelW, rect.x + rect.w) - Math.max(lx, rect.x));
const oy = Math.max(0, Math.min(ly + LABEL_H, rect.y + rect.h) - Math.max(ly, rect.y));
overlap += ox * oy;
}
if (overlap < bestOverlap) {
bestOverlap = overlap;
best = cand;
}
if (overlap === 0) break;
}
const finalX = x + best.ox;
const finalY = y + best.oy;
const lx = best.anchor === 'middle' ? (finalX - labelW / 2)
: best.anchor === 'start' ? finalX
: (finalX - labelW);
placedRects.push({ x: lx, y: finalY - LABEL_H, w: labelW, h: LABEL_H });
return (
<text x={finalX} y={finalY} textAnchor={best.anchor} fill="#475569" fontSize={10} fontWeight={500}>
{value}
</text>
);
};
// Compute Y domain with padding for gradient zones
const yValues = data.map(d => d.y);
const yMin = Math.min(-20, ...yValues) - 10;
const yMax = Math.max(20, ...yValues) + 10;
return (
<div ref={containerRef}>
<ResponsiveContainer width="100%" height={CHART_H}>
<ScatterChart margin={CHART_MARGIN}>
{/* Gradient background zones */}
<defs>
<linearGradient id="positiveZone" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.02} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.08} />
</linearGradient>
<linearGradient id="negativeZone" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.02} />
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.08} />
</linearGradient>
</defs>
<ReferenceArea y1={0} y2={yMax} fill="url(#positiveZone)" strokeOpacity={0} />
<ReferenceArea y1={yMin} y2={0} fill="url(#negativeZone)" strokeOpacity={0} />
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
type="number"
dataKey="x"
name="Frequency"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
label={{ value: axisLabels.frequency, position: 'insideBottom', offset: -5, fill: '#9ca3af', fontSize: 12 }}
/>
<YAxis
type="number"
dataKey="y"
name="Sentiment"
domain={[yMin, yMax]}
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
label={{ value: axisLabels.sentiment, angle: -90, position: 'insideLeft', fill: '#9ca3af', fontSize: 12 }}
/>
<ReferenceLine y={0} stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="" />
<ZAxis type="number" dataKey="z" range={[100, 600]} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const point = (payload[0] as any)?.payload as DataPoint | undefined;
if (!point) return null;
return (
<div style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '8px 12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: '#1E293B', marginBottom: 4 }}>{point.name}</div>
<div style={{ fontSize: 12, color: '#6b7280' }}>
{locale === 'es' ? 'Menciones' : 'Mentions'}: <strong>{point.x}</strong>
</div>
<div style={{ fontSize: 12, color: '#6b7280' }}>
{locale === 'es' ? 'Sentimiento Neto' : 'Net Sentiment'}: <strong>{point.y}%</strong>
</div>
</div>
);
}}
/>
<Scatter data={data}>
{data.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
<LabelList dataKey="name" content={renderLabel} />
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import React from 'react';
// === BRAND COLORS ===
const brandColors = {
star: '#FBBC05',
magnifier: '#1E293B',
magnifierDark: '#1E293B',
barLight: '#86EFAC',
barMid: '#22C55E',
barDark: '#15803D',
lensLight: '#FEF3C7',
lensDark: '#FEF3C7',
wordmark: '#1E293B',
wordmarkDark: '#FAFAFA',
wordmarkAccent: '#F59E0B',
tagline: '#64748B',
taglineDark: '#A3A3A3',
} as const;
const logoRatios = {
wordmarkFont: 0.18,
taglineFont: 0.11,
gapIconToWordmark: 0.08,
gapWordmarkToTagline: 0.04,
clearSpace: 0.15,
} as const;
interface WhyMyRatingLogoProps {
size?: number;
variant?: 'icon' | 'primary' | 'full' | 'horizontal';
colorScheme?: 'light' | 'dark';
}
function WhyMyRatingLogo({ size = 120, variant = 'primary', colorScheme = 'light' }: WhyMyRatingLogoProps) {
const u = size;
const calc = {
icon: u,
wordmarkFont: u * logoRatios.wordmarkFont,
taglineFont: u * logoRatios.taglineFont,
gapIconToWordmark: u * logoRatios.gapIconToWordmark,
gapWordmarkToTagline: u * logoRatios.gapWordmarkToTagline,
clearSpace: u * logoRatios.clearSpace,
};
const isDark = colorScheme === 'dark';
const magnifierColor = isDark ? brandColors.magnifierDark : brandColors.magnifier;
const lensColor = isDark ? brandColors.lensDark : brandColors.lensLight;
const wordmarkColor = isDark ? brandColors.wordmarkDark : brandColors.wordmark;
const taglineColor = isDark ? brandColors.taglineDark : brandColors.tagline;
const strokeColor = isDark ? '#475569' : 'none';
const clipId = `circleClip-${size}-${variant}-${colorScheme}-${Math.random().toString(36).substr(2, 9)}`;
const LogoIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width={calc.icon} height={calc.icon} aria-label="WhyMyRating logo icon">
<defs>
<clipPath id={clipId}>
<circle cx="60" cy="62" r="21"/>
</clipPath>
</defs>
<polygon points="60,15 71.5,42 101,46 79.5,66 85,95 60,82 35,95 40.5,66 19,46 48.5,42" fill={brandColors.star} stroke={brandColors.star} strokeWidth="6" strokeLinejoin="round"/>
<g>
{isDark && <line x1="83" y1="81" x2="95" y2="91" stroke="#475569" strokeWidth="11" strokeLinecap="round"/>}
<circle cx="60" cy="62" r="27" fill={magnifierColor} stroke={strokeColor} strokeWidth={isDark ? 1.5 : 0}/>
<line x1="83" y1="81" x2="95" y2="91" stroke={magnifierColor} strokeWidth="9" strokeLinecap="round"/>
<circle cx="60" cy="62" r="21" fill={lensColor}/>
<g clipPath={`url(#${clipId})`}>
<rect x="42" y="58" width="11" height="35" rx="1.5" ry="1.5" fill={brandColors.barLight}/>
<rect x="55" y="51" width="11" height="42" rx="1.5" ry="1.5" fill={brandColors.barMid}/>
<rect x="68" y="44" width="11" height="49" rx="1.5" ry="1.5" fill={brandColors.barDark}/>
</g>
<rect x="68" y="44" width="11" height="18" rx="1.5" ry="1.5" fill={brandColors.barDark}/>
</g>
</svg>
);
const Wordmark = () => (
<span style={{
fontFamily: "'Nunito', sans-serif",
fontWeight: 700,
fontSize: calc.wordmarkFont,
color: wordmarkColor,
lineHeight: 1.2,
letterSpacing: '-0.01em',
}}>
whyrating<span style={{ color: brandColors.wordmarkAccent }}>.com</span>
</span>
);
const Tagline = () => (
<span style={{
fontFamily: "'Inter', sans-serif",
fontWeight: 400,
fontSize: calc.taglineFont,
color: taglineColor,
lineHeight: 1.4,
}}>
The story behind your stars
</span>
);
const containerStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
padding: calc.clearSpace,
};
if (variant === 'icon') {
return <div style={{ display: 'inline-flex', padding: calc.clearSpace }}><LogoIcon /></div>;
}
if (variant === 'primary') {
return (
<div style={containerStyle}>
<LogoIcon />
<div style={{ height: calc.gapIconToWordmark }} />
<Wordmark />
</div>
);
}
if (variant === 'full') {
return (
<div style={containerStyle}>
<LogoIcon />
<div style={{ height: calc.gapIconToWordmark }} />
<Wordmark />
<div style={{ height: calc.gapWordmarkToTagline }} />
<Tagline />
</div>
);
}
if (variant === 'horizontal') {
return (
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: calc.gapIconToWordmark }}>
<LogoIcon />
<Wordmark />
</div>
);
}
return null;
}
// === REPORT LOGO WRAPPER ===
interface ReportLogoProps {
variant: 'header' | 'cover' | 'footer';
}
export default function ReportLogo({ variant }: ReportLogoProps) {
if (variant === 'cover') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<WhyMyRatingLogo size={90} variant="full" colorScheme="dark" />
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.12em', textTransform: 'uppercase' as const }}>
Reputation Intelligence
</span>
</div>
);
}
const size = variant === 'header' ? 80 : 60;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WhyMyRatingLogo size={size} variant="horizontal" colorScheme="light" />
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import ReportLogo from './ReportLogo';
interface ReportPageProps {
pageNumber: number;
totalPages: number;
background?: 'white' | 'gray' | 'dark';
showHeader?: boolean;
children: React.ReactNode;
}
export default function ReportPage({
pageNumber,
totalPages,
background = 'white',
showHeader = true,
children,
}: ReportPageProps) {
const bgClass = background === 'gray' ? 'page-bg-gray' : background === 'dark' ? 'page-bg-dark' : 'page-bg-white';
return (
<div className={`page ${bgClass}`} data-report-page>
<div className="page-content">
{showHeader && background !== 'dark' && (
<div className="page-header">
<div className="page-header-logo">
<ReportLogo variant="header" />
</div>
<div className="page-header-right">
<span className="page-indicator">
{pageNumber} / {totalPages}
</span>
</div>
</div>
)}
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{children}
</div>
{background !== 'dark' && (
<div className="page-footer">
<ReportLogo variant="footer" />
<span className="page-footer-text">
Confidential Prepared by whyrating.com
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
interface SectionHeaderProps {
number: number | string;
title: string;
subtitle?: string;
}
export default function SectionHeader({ number, title, subtitle }: SectionHeaderProps) {
return (
<div className="section-header">
<div className="section-number">{number}</div>
<h2 className="display-md">{title}</h2>
{subtitle && <p className="body-md section-lead text-secondary">{subtitle}</p>}
</div>
);
}

View File

@@ -0,0 +1,113 @@
/**
* Translation maps for content that comes from the backend in English.
* Domain labels, primitive labels, level words, and timeline phrases.
*/
// Domain labels (backend sends these in English)
const DOMAIN_LABELS: Record<string, Record<string, string>> = {
es: {
'People/Service': 'Personas/Servicio',
'Output/Product': 'Producto/Resultado',
'Journey/Process': 'Proceso/Recorrido',
'Environment': 'Entorno',
'Value': 'Valor',
'Meta': 'Confianza',
},
};
// Primitive code → localized display label
const PRIMITIVE_LABELS: Record<string, Record<string, string>> = {
en: {
TASTE: 'Taste', CRAFT: 'Craft', FRESHNESS: 'Freshness', TEMPERATURE: 'Temperature',
EFFECTIVENESS: 'Effectiveness', ACCURACY: 'Accuracy', CONDITION: 'Condition', CONSISTENCY: 'Consistency',
MANNER: 'Manner/Attitude', COMPETENCE: 'Competence', ATTENTIVENESS: 'Attentiveness', COMMUNICATION: 'Communication',
SPEED: 'Speed', FRICTION: 'Friction', RELIABILITY: 'Reliability', AVAILABILITY: 'Availability',
CLEANLINESS: 'Cleanliness', COMFORT: 'Comfort', SAFETY: 'Safety', AMBIANCE: 'Ambiance',
ACCESSIBILITY: 'Accessibility', DIGITAL_UX: 'Digital UX',
PRICE_LEVEL: 'Price Level', PRICE_FAIRNESS: 'Price Fairness',
PRICE_TRANSPARENCY: 'Price Transparency', VALUE_FOR_MONEY: 'Value for Money',
HONESTY: 'Honesty', ETHICS: 'Ethics', PROMISES: 'Promises', ACKNOWLEDGMENT: 'Acknowledgment',
RESPONSE_QUALITY: 'Response Quality', RECOVERY: 'Recovery',
RETURN_INTENT: 'Return Intent', RECOMMEND: 'Recommendation',
RECOGNITION: 'Recognition', UNMAPPED: 'Unmapped', NON_INFORMATIVE: 'Non-informative',
},
es: {
TASTE: 'Sabor', CRAFT: 'Artesanía', FRESHNESS: 'Frescura', TEMPERATURE: 'Temperatura',
EFFECTIVENESS: 'Efectividad', ACCURACY: 'Precisión', CONDITION: 'Condición', CONSISTENCY: 'Consistencia',
MANNER: 'Trato/Actitud', COMPETENCE: 'Competencia', ATTENTIVENESS: 'Atención', COMMUNICATION: 'Comunicación',
SPEED: 'Velocidad', FRICTION: 'Fricción', RELIABILITY: 'Confiabilidad', AVAILABILITY: 'Disponibilidad',
CLEANLINESS: 'Limpieza', COMFORT: 'Comodidad', SAFETY: 'Seguridad', AMBIANCE: 'Ambiente',
ACCESSIBILITY: 'Accesibilidad', DIGITAL_UX: 'Experiencia Digital',
PRICE_LEVEL: 'Nivel de Precio', PRICE_FAIRNESS: 'Equidad de Precio',
PRICE_TRANSPARENCY: 'Transparencia de Precio', VALUE_FOR_MONEY: 'Relación Calidad-Precio',
HONESTY: 'Honestidad', ETHICS: 'Ética', PROMISES: 'Promesas', ACKNOWLEDGMENT: 'Reconocimiento',
RESPONSE_QUALITY: 'Calidad de Respuesta', RECOVERY: 'Recuperación',
RETURN_INTENT: 'Intención de Retorno', RECOMMEND: 'Recomendación',
RECOGNITION: 'Reconocimiento', UNMAPPED: 'No Clasificado', NON_INFORMATIVE: 'No Informativo',
},
};
// English theme labels → Spanish (backend sends human-readable labels)
const THEME_LABELS: Record<string, Record<string, string>> = {
es: {
'Taste': 'Sabor', 'Craft': 'Artesanía', 'Freshness': 'Frescura', 'Temperature': 'Temperatura',
'Effectiveness': 'Efectividad', 'Accuracy': 'Precisión', 'Condition': 'Condición', 'Consistency': 'Consistencia',
'Manner': 'Trato', 'Manner/Attitude': 'Trato/Actitud', 'Competence': 'Competencia',
'Attentiveness': 'Atención', 'Communication': 'Comunicación',
'Speed': 'Velocidad', 'Friction': 'Fricción', 'Reliability': 'Confiabilidad', 'Availability': 'Disponibilidad',
'Cleanliness': 'Limpieza', 'Comfort': 'Comodidad', 'Safety': 'Seguridad', 'Ambiance': 'Ambiente',
'Accessibility': 'Accesibilidad', 'Digital UX': 'Experiencia Digital',
'Price Level': 'Nivel de Precio', 'Price Fairness': 'Equidad de Precio',
'Price Transparency': 'Transparencia de Precio', 'Value for Money': 'Relación Calidad-Precio',
'Honesty': 'Honestidad', 'Ethics': 'Ética', 'Promises': 'Promesas', 'Acknowledgment': 'Reconocimiento',
'Response Quality': 'Calidad de Respuesta', 'Recovery': 'Recuperación',
'Return Intent': 'Intención de Retorno', 'Recommend': 'Recomendación', 'Recommendation': 'Recomendación',
'Recognition': 'Reconocimiento', 'Unmapped': 'No Clasificado', 'Non-informative': 'No Informativo',
},
};
// Level words (high/medium/low → alto/medio/bajo)
const LEVEL_WORDS: Record<string, Record<string, string>> = {
es: {
high: 'alto', medium: 'medio', low: 'bajo',
High: 'Alto', Medium: 'Medio', Low: 'Bajo',
},
};
// Common timeline phrases
const TIMELINE_PHRASES: Record<string, Record<string, string>> = {
es: {
'This month': 'Este mes',
'This quarter': 'Este trimestre',
'This week': 'Esta semana',
'Next month': 'Próximo mes',
'Next quarter': 'Próximo trimestre',
'Immediately': 'Inmediatamente',
'Ongoing': 'Continuo',
},
};
/** Translate a domain label (e.g., "People/Service" → "Personas/Servicio") */
export function translateDomain(label: string, locale: string): string {
return DOMAIN_LABELS[locale]?.[label] ?? label;
}
/** Translate a primitive code to its localized display name */
export function translatePrimitive(code: string, locale: string): string {
return PRIMITIVE_LABELS[locale]?.[code] ?? PRIMITIVE_LABELS.en?.[code] ?? code;
}
/** Translate a theme label (e.g., "Honesty" → "Honestidad") */
export function translateThemeLabel(label: string, locale: string): string {
return THEME_LABELS[locale]?.[label] ?? label;
}
/** Translate a level word (e.g., "high" → "alto") */
export function translateLevel(level: string, locale: string): string {
return LEVEL_WORDS[locale]?.[level] ?? level;
}
/** Translate a timeline phrase (e.g., "This month" → "Este mes") */
export function translateTimeline(timeline: string, locale: string): string {
return TIMELINE_PHRASES[locale]?.[timeline] ?? timeline;
}

View File

@@ -0,0 +1,418 @@
export const translations: Record<string, Record<string, string>> = {
en: {
// Report title
report_title: 'Reputation Blueprint',
confidential: 'Confidential — Prepared by whyrating.com',
reputation_intelligence: 'Reputation Intelligence',
// Cover page
reviews_analyzed: 'Reviews Analyzed',
reputation_score: 'Reputation Score',
critical_issues_count: 'Critical Issues',
current_rating: 'Current Rating',
potential_rating: 'Potential Rating',
// Executive Summary (Section 1)
executive_summary: 'Executive Summary',
executive_summary_lead: 'A high-level overview of your online reputation health',
score_breakdown: 'Score Breakdown',
key_findings: 'Key Findings',
revenue_impact: 'Revenue Impact',
top_priority_actions: 'Top Priority Actions',
// Score pillars
pillar_rating_quality: 'Rating Quality',
pillar_sentiment_depth: 'Sentiment Depth',
pillar_volume: 'Volume',
pillar_momentum: 'Momentum',
pillar_intensity: 'Intensity',
// Rating Dashboard (Section 2)
rating_dashboard: 'Rating Dashboard',
rating_dashboard_lead: 'A data-driven snapshot of your review performance',
distribution: 'Distribution',
rating_trend: 'Rating Trend',
sentiment_distribution: 'Sentiment Distribution',
out_of_5: 'out of 5',
out_of_100: 'out of 100',
achievable: 'achievable',
// Theme Analysis (Section 3)
theme_analysis: 'Theme Analysis',
theme_analysis_lead: 'What your customers are talking about most',
frequency_vs_sentiment: 'Frequency vs Sentiment',
sentiment_momentum: 'Sentiment Momentum',
intensity_distribution: 'Intensity Distribution',
pain_points: 'Pain Points',
what_customers_love: 'What Customers Love',
complaints: 'complaints',
mentions: 'mentions',
// Domain Performance (Section 4)
domain_performance: 'Domain Performance',
domain_performance_lead: 'Performance breakdown across key experience dimensions',
weight: 'Weight',
primitives: 'primitives',
domain_weight_suffix: 'of overall score',
domain_aspects_suffix: 'aspects analyzed',
// Critical Issues (Section 5)
critical_issues: 'Critical Issues',
critical_issues_lead: 'Top problems requiring immediate attention',
evidence: 'Evidence',
recommended_solution: 'Recommended Solution',
quick_fix: 'Quick Fix',
moderate: 'Moderate',
complex: 'Complex',
score_cost: 'Reputation Cost',
score_cost_pts: 'pts lost',
// Strengths (Section 6)
protect_strengths: 'Protect Your Strengths',
protect_strengths_lead: 'Competitive advantages to leverage and protect',
customer_voices: 'Customer Voices',
marketing_angle: 'Marketing Angle',
// Action Plan (Section 7)
action_plan: 'Action Plan',
action_plan_lead: 'Prioritized actions to improve your reputation',
quick_wins_30d: '30-Day Quick Wins',
strategic_90d: '90-Day Strategic Initiatives',
owner: 'Owner',
source: 'Source',
success_metric: 'Success',
effort: 'effort',
impact: 'impact',
low: 'Low',
medium: 'Medium',
high: 'High',
// Tracking Framework (Section 8)
tracking_framework: '90-Day Tracking Framework',
tracking_framework_lead: 'Key metrics to monitor your reputation improvement',
metric: 'Metric',
current: 'Current',
target_30d: '30-Day Target',
target_90d: '90-Day Target',
// Staff Leaderboard (Section 7)
staff_leaderboard: 'Staff Leaderboard',
staff_leaderboard_lead: 'Team members your customers mention most',
staff_rank: 'Rank',
staff_name: 'Name',
staff_mentions: 'Mentions',
staff_sentiment: 'Sentiment',
staff_positive: 'Positive',
staff_negative: 'Negative',
staff_top_performer: 'Top Performer',
staff_disclaimer: 'Note: Customers may refer to the same team member using different name variations (e.g., first name only vs. full name), which can split their results across multiple entries.',
staff_individuals: 'Individuals',
staff_groups: 'Groups',
staff_observations: 'Observations',
staff_role: 'Role',
action_detail: 'Detail',
action_evidence: 'Evidence',
// End Page
end_page_title: 'Conclusion & Next Steps',
key_takeaways: 'Key Takeaways',
ninety_day_focus: '90-Day Focus',
review_cadence: 'Review Cadence',
cost_of_inaction: 'Cost of Inaction',
view_dashboard: 'View Live Dashboard',
confidential_footer: 'Confidential — For internal use only',
// Cover
reporting_period: 'Reporting Period',
// Appendix
appendix_review_evidence: 'Appendix: Review Evidence',
appendix_lead: 'Full review text with classified opinion anchors',
// Review Evidence (Section)
review_evidence: 'Review Evidence',
review_evidence_lead: 'Full review text with classified opinion anchors',
review_by: 'Review by',
classifications_label: 'Classifications',
anonymous: 'Anonymous',
// Trends & Timeline (Section 5)
trends_timeline: 'Trends & Timeline',
trends_timeline_lead: 'How your reputation has evolved over time',
quarterly_rating_evolution: 'Rating Evolution',
domain_sentiment_trend: 'Domain Sentiment Trend',
seasonal_pattern: 'Seasonal Pattern',
// Chart axis labels
frequency: 'Frequency',
net_sentiment: 'Net Sentiment %',
// Score bands
score_excellent: 'Excellent',
score_good: 'Good',
score_fair: 'Fair',
score_poor: 'Poor',
score_critical: 'Critical',
// Table of Contents
table_of_contents: 'Table of Contents',
page: 'Page',
toc_desc_executive_summary: 'Overall reputation health, score pillars, and key findings',
toc_desc_rating_dashboard: 'Star rating distribution, trends, and sentiment breakdown',
toc_desc_theme_analysis: 'Most discussed topics, pain points, and what customers love',
toc_desc_domain_performance: 'Scores across product, people, process, environment, and value',
toc_desc_trends_timeline: 'Quarterly trends, domain evolution, and seasonal patterns',
toc_desc_critical_issues: 'Top problems to fix, with evidence and recommended solutions',
toc_desc_strengths: 'Competitive advantages to protect and leverage',
toc_desc_staff_leaderboard: 'Team members most mentioned by customers',
toc_desc_action_plan: 'Prioritized next steps with owners and success metrics',
toc_desc_tracking_framework: 'KPIs to monitor at 30 and 90 days',
toc_desc_review_evidence: 'Full review text with classified opinion anchors',
// How to Read
how_to_read: 'How to Read This Report',
how_to_read_lead: 'A quick guide to the symbols, scores, and structure used throughout',
how_to_read_score_title: 'Reputation Score (0100)',
how_to_read_score_desc: 'A composite score based on five pillars: Rating Quality, Sentiment Depth, Volume, Momentum, and Intensity. Higher is better.',
how_to_read_bands_title: 'Score Bands',
how_to_read_valence_title: 'Sentiment Markers',
how_to_read_valence_desc: 'Each customer opinion is tagged with a valence showing its tone.',
how_to_read_valence_pos: 'Positive — customer praised this aspect',
how_to_read_valence_neg: 'Negative — customer complained about this',
how_to_read_valence_neu: 'Neutral — mentioned without clear sentiment',
how_to_read_valence_mix: 'Mixed — both positive and negative in one statement',
how_to_read_domains_title: 'Experience Domains',
how_to_read_domains_desc: 'Reviews are classified into five experience dimensions.',
how_to_read_domain_o: 'Output — quality of the product or service delivered',
how_to_read_domain_p: 'People — staff behavior, competence, and communication',
how_to_read_domain_j: 'Journey — speed, friction, and reliability of the process',
how_to_read_domain_e: 'Environment — cleanliness, comfort, safety, ambiance',
how_to_read_domain_v: 'Value — pricing, fairness, and value for money',
how_to_read_intensity_title: 'Intensity Levels',
how_to_read_intensity_desc: 'How strongly customers feel about each mention.',
how_to_read_intensity_1: 'Mild — passing mention, low emphasis',
how_to_read_intensity_2: 'Moderate — clear opinion with some detail',
how_to_read_intensity_3: 'Strong — emphatic language, high conviction',
how_to_read_tips_title: 'Quick Reading Tips',
how_to_read_tip_1: 'Scores above 75 indicate strong reputation health. Below 40 needs urgent attention.',
how_to_read_tip_2: 'High frequency + negative sentiment = your top priority to fix.',
how_to_read_tip_3: 'Customer quotes appear in italic throughout — real voices, not summaries.',
how_to_read_tip_4: 'Each section is self-contained. Jump to what matters most to you.',
// Pagination
continued: 'continued',
// Language selector
language: 'Language',
lang_en: 'English',
lang_es: 'Español',
},
es: {
// Report title
report_title: 'Informe de Reputación',
confidential: 'Confidencial — Preparado por whyrating.com',
reputation_intelligence: 'Inteligencia de Reputación',
// Cover page
reviews_analyzed: 'Reseñas Analizadas',
reputation_score: 'Puntuación de Reputación',
critical_issues_count: 'Problemas Críticos',
current_rating: 'Calificación Actual',
potential_rating: 'Calificación Potencial',
// Executive Summary (Section 1)
executive_summary: 'Resumen Ejecutivo',
executive_summary_lead: 'Una visión general de la salud de su reputación online',
score_breakdown: 'Desglose de Puntuación',
key_findings: 'Hallazgos Clave',
revenue_impact: 'Impacto en Ingresos',
top_priority_actions: 'Acciones Prioritarias',
// Score pillars
pillar_rating_quality: 'Calidad de Calificación',
pillar_sentiment_depth: 'Profundidad de Sentimiento',
pillar_volume: 'Volumen',
pillar_momentum: 'Impulso',
pillar_intensity: 'Intensidad',
// Rating Dashboard (Section 2)
rating_dashboard: 'Panel de Calificaciones',
rating_dashboard_lead: 'Una visión basada en datos de su rendimiento en reseñas',
distribution: 'Distribución',
rating_trend: 'Tendencia de Calificaciones',
sentiment_distribution: 'Distribución de Sentimiento',
out_of_5: 'de 5',
out_of_100: 'de 100',
achievable: 'alcanzable',
// Theme Analysis (Section 3)
theme_analysis: 'Análisis de Temas',
theme_analysis_lead: 'Lo que más comentan sus clientes',
frequency_vs_sentiment: 'Frecuencia vs Sentimiento',
sentiment_momentum: 'Impulso de Sentimiento',
intensity_distribution: 'Distribución de Intensidad',
pain_points: 'Puntos de Dolor',
what_customers_love: 'Lo Que Aman los Clientes',
complaints: 'quejas',
mentions: 'menciones',
// Domain Performance (Section 4)
domain_performance: 'Rendimiento por Dominio',
domain_performance_lead: 'Desglose de rendimiento en dimensiones clave de experiencia',
weight: 'Peso',
primitives: 'primitivas',
domain_weight_suffix: 'del puntaje total',
domain_aspects_suffix: 'aspectos analizados',
// Critical Issues (Section 5)
critical_issues: 'Problemas Críticos',
critical_issues_lead: 'Problemas principales que requieren atención inmediata',
evidence: 'Evidencia',
recommended_solution: 'Solución Recomendada',
quick_fix: 'Corrección Rápida',
moderate: 'Moderado',
complex: 'Complejo',
score_cost: 'Coste Reputacional',
score_cost_pts: 'pts perdidos',
// Strengths (Section 6)
protect_strengths: 'Proteja Sus Fortalezas',
protect_strengths_lead: 'Ventajas competitivas para aprovechar y proteger',
customer_voices: 'Voces de Clientes',
marketing_angle: 'Ángulo de Marketing',
// Action Plan (Section 7)
action_plan: 'Plan de Acción',
action_plan_lead: 'Acciones priorizadas para mejorar su reputación',
quick_wins_30d: 'Victorias Rápidas (30 Días)',
strategic_90d: 'Iniciativas Estratégicas (90 Días)',
owner: 'Responsable',
source: 'Fuente',
success_metric: 'Éxito',
effort: 'esfuerzo',
impact: 'impacto',
low: 'Bajo',
medium: 'Medio',
high: 'Alto',
// Tracking Framework (Section 8)
tracking_framework: 'Marco de Seguimiento a 90 Días',
tracking_framework_lead: 'Métricas clave para monitorear la mejora de su reputación',
metric: 'Métrica',
current: 'Actual',
target_30d: 'Objetivo 30 Días',
target_90d: 'Objetivo 90 Días',
// Staff Leaderboard (Section 7)
staff_leaderboard: 'Ranking del Equipo',
staff_leaderboard_lead: 'Los miembros del equipo más mencionados por sus clientes',
staff_rank: 'Posición',
staff_name: 'Nombre',
staff_mentions: 'Menciones',
staff_sentiment: 'Sentimiento',
staff_positive: 'Positivo',
staff_negative: 'Negativo',
staff_top_performer: 'Mejor Valorado',
staff_disclaimer: 'Nota: Los clientes pueden referirse al mismo miembro del equipo con diferentes variaciones de nombre (ej. solo nombre vs. nombre completo), lo que puede dividir sus resultados en varias entradas.',
staff_individuals: 'Individuos',
staff_groups: 'Grupos',
staff_observations: 'Observaciones',
staff_role: 'Rol',
action_detail: 'Detalle',
action_evidence: 'Evidencia',
// End Page
end_page_title: 'Conclusión y Próximos Pasos',
key_takeaways: 'Conclusiones Clave',
ninety_day_focus: 'Foco a 90 Días',
review_cadence: 'Cadencia de Revisión',
cost_of_inaction: 'Costo de la Inacción',
view_dashboard: 'Ver Panel en Vivo',
confidential_footer: 'Confidencial — Solo para uso interno',
// Cover
reporting_period: 'Período de Análisis',
// Appendix
appendix_review_evidence: 'Apéndice: Evidencia de Reseñas',
appendix_lead: 'Texto completo de reseñas con opiniones clasificadas',
// Review Evidence (Section)
review_evidence: 'Evidencia de Reseñas',
review_evidence_lead: 'Texto completo de reseñas con opiniones clasificadas',
review_by: 'Reseña de',
classifications_label: 'Clasificaciones',
anonymous: 'Anónimo',
// Trends & Timeline (Section 5)
trends_timeline: 'Tendencias y Cronología',
trends_timeline_lead: 'Cómo ha evolucionado su reputación a lo largo del tiempo',
quarterly_rating_evolution: 'Evolución de Calificación',
domain_sentiment_trend: 'Tendencia de Sentimiento por Dominio',
seasonal_pattern: 'Patrón Estacional',
// Chart axis labels
frequency: 'Frecuencia',
net_sentiment: 'Sentimiento Neto %',
// Score bands
score_excellent: 'Excelente',
score_good: 'Bueno',
score_fair: 'Regular',
score_poor: 'Malo',
score_critical: 'Crítico',
// Table of Contents
table_of_contents: 'Índice',
page: 'Página',
toc_desc_executive_summary: 'Salud reputacional, pilares de puntuación y hallazgos clave',
toc_desc_rating_dashboard: 'Distribución de estrellas, tendencias y desglose de sentimiento',
toc_desc_theme_analysis: 'Temas más discutidos, puntos de dolor y lo que los clientes valoran',
toc_desc_domain_performance: 'Puntuaciones en producto, personas, procesos, entorno y valor',
toc_desc_trends_timeline: 'Tendencias trimestrales, evolución por dominio y patrones estacionales',
toc_desc_critical_issues: 'Principales problemas a resolver, con evidencia y soluciones',
toc_desc_strengths: 'Ventajas competitivas para proteger y aprovechar',
toc_desc_staff_leaderboard: 'Miembros del equipo más mencionados por los clientes',
toc_desc_action_plan: 'Próximos pasos priorizados con responsables y métricas de éxito',
toc_desc_tracking_framework: 'KPIs a monitorear a 30 y 90 días',
toc_desc_review_evidence: 'Texto completo de reseñas con opiniones clasificadas',
// How to Read
how_to_read: 'Cómo Leer Este Informe',
how_to_read_lead: 'Guía rápida de los símbolos, puntuaciones y estructura utilizados',
how_to_read_score_title: 'Puntuación de Reputación (0100)',
how_to_read_score_desc: 'Puntuación compuesta basada en cinco pilares: Calidad de Calificación, Profundidad de Sentimiento, Volumen, Impulso e Intensidad. Mayor es mejor.',
how_to_read_bands_title: 'Bandas de Puntuación',
how_to_read_valence_title: 'Marcadores de Sentimiento',
how_to_read_valence_desc: 'Cada opinión se etiqueta con una valencia que indica su tono.',
how_to_read_valence_pos: 'Positivo — el cliente elogió este aspecto',
how_to_read_valence_neg: 'Negativo — el cliente se quejó de esto',
how_to_read_valence_neu: 'Neutral — mencionado sin sentimiento claro',
how_to_read_valence_mix: 'Mixto — tanto positivo como negativo en una declaración',
how_to_read_domains_title: 'Dominios de Experiencia',
how_to_read_domains_desc: 'Las reseñas se clasifican en cinco dimensiones de experiencia.',
how_to_read_domain_o: 'Producto — calidad del producto o servicio entregado',
how_to_read_domain_p: 'Personas — comportamiento, competencia y comunicación del personal',
how_to_read_domain_j: 'Proceso — velocidad, fricción y fiabilidad del proceso',
how_to_read_domain_e: 'Entorno — limpieza, comodidad, seguridad, ambiente',
how_to_read_domain_v: 'Valor — precios, equidad y relación calidad-precio',
how_to_read_intensity_title: 'Niveles de Intensidad',
how_to_read_intensity_desc: 'Qué tan fuertemente sienten los clientes sobre cada mención.',
how_to_read_intensity_1: 'Leve — mención pasajera, bajo énfasis',
how_to_read_intensity_2: 'Moderado — opinión clara con algo de detalle',
how_to_read_intensity_3: 'Fuerte — lenguaje enfático, alta convicción',
how_to_read_tips_title: 'Consejos de Lectura',
how_to_read_tip_1: 'Puntuaciones superiores a 75 indican buena salud reputacional. Por debajo de 40 requiere atención urgente.',
how_to_read_tip_2: 'Alta frecuencia + sentimiento negativo = su prioridad principal a resolver.',
how_to_read_tip_3: 'Las citas de clientes aparecen en cursiva — son voces reales, no resúmenes.',
how_to_read_tip_4: 'Cada sección es independiente. Vaya directamente a lo que más le interese.',
// Pagination
continued: 'continuación',
// Language selector
language: 'Idioma',
lang_en: 'English',
lang_es: 'Español',
},
};

View File

@@ -0,0 +1,11 @@
import { translations } from './translations';
export type ReportLocale = 'en' | 'es';
export function useReportLocale(locale: ReportLocale = 'en') {
const t = (key: string): string => {
return translations[locale]?.[key] ?? translations.en?.[key] ?? key;
};
return { t, locale };
}

View File

@@ -0,0 +1,105 @@
'use client';
import type { ReportSynthesis, ActionItem } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { translateLevel, translateTimeline, translatePrimitive } from '../i18n/contentTranslations';
import { paginateItems } from '../utils/paginate';
interface ActionPlanProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number;
}
export default function ActionPlan({ report, locale = 'en', startPage = 8, totalPages = 9, sectionNumber = 7 }: ActionPlanProps) {
const { t } = useReportLocale(locale);
const actions = report.actions;
if (actions.length === 0) return null;
// Build primitive → mention count map from themes
const mentionMap = new Map<string, number>();
for (const theme of report.themes) {
mentionMap.set(theme.primitive, theme.count);
}
const pages = paginateItems(actions, 3, 4);
return (
<>
{pages.map((pageItems, pageIdx) => {
// Compute global offset for numbering
let globalOffset = 0;
for (let p = 0; p < pageIdx; p++) globalOffset += pages[p]!.length;
return (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="white">
{pageIdx === 0 ? (
<SectionHeader
number={sectionNumber}
title={t('action_plan')}
subtitle={t('action_plan_lead')}
/>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('action_plan')} ({t('continued')})
</div>
)}
<div>
{pageItems.map((action, i) => (
<ActionCard key={globalOffset + i} action={action} index={globalOffset + i} t={t} locale={locale} mentionCount={mentionMap.get(action.source)} />
))}
</div>
</ReportPage>
);
})}
</>
);
}
function ActionCard({ action, index, t, locale, mentionCount }: { action: ActionItem; index: number; t: (key: string) => string; locale: string; mentionCount?: number }) {
return (
<div className="framework-item" style={{ padding: 14 }}>
<div className="framework-number" style={{ width: 30, height: 30, fontSize: 13 }}>{index + 1}</div>
<div className="framework-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div className="heading-md" style={{ fontSize: 14, marginBottom: 4 }}>{action.action}</div>
<div className="body-sm">
{t('owner')}: <strong>{action.owner}</strong> &middot; {t('source')}: {translatePrimitive(action.source, locale)}
{mentionCount != null && (
<span style={{ color: 'var(--ui-primary, #4285F4)', fontWeight: 600 }}> ({mentionCount} {t('mentions')})</span>
)}
{' '}&middot; {translateTimeline(action.timeline, locale)}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<span className={`effort-badge ${action.effort}`}>{translateLevel(action.effort, locale)} {t('effort')}</span>
<span className={`impact-badge ${action.impact}`}>{translateLevel(action.impact, locale)} {t('impact')}</span>
</div>
</div>
{action.success_metric && (
<div className="info-block" style={{ marginTop: 8, padding: '8px 12px' }}>
<span className="body-sm"><strong>{t('success_metric')}:</strong> {action.success_metric}</span>
</div>
)}
{action.detail && (
<p className="body-sm" style={{ marginTop: 8, lineHeight: 1.5, color: 'var(--text-secondary)' }}>
{action.detail}
</p>
)}
{action.evidence && (
<div className="evidence-quote" style={{ marginTop: 8, padding: '8px 12px', fontSize: 12 }}>
&ldquo;{action.evidence}&rdquo;
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import ReportLogo from '../components/ReportLogo';
import ReputationScoreGauge from '../charts/ReputationScoreGauge';
import { getScoreColor } from '../styles/report-theme';
interface CoverPageProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function CoverPage({ report, locale = 'en', pageNumber = 1, totalPages = 9 }: CoverPageProps) {
const { t } = useReportLocale(locale);
const score = report.reputation_score;
const scoreColor = getScoreColor(score);
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="dark" showHeader={false}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', textAlign: 'center' }}>
{/* Logo */}
<ReportLogo variant="cover" />
{/* Business Name */}
<h1 className="display-xl text-inverse" style={{ marginTop: 40, marginBottom: 12, maxWidth: 700 }}>
{report.business_name}
</h1>
{/* Category badge */}
{report.category_label && (
<span className="pill dark" style={{ marginBottom: 8 }}>
{report.category_label}
</span>
)}
{/* Date */}
<p className="body-md" style={{ color: 'rgba(255,255,255,0.5)', marginBottom: 8 }}>
{new Date(report.report_date).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
{/* Reporting Period */}
{report.methodology?.oldest_review && report.methodology?.newest_review && (
<p className="body-sm" style={{ color: 'rgba(255,255,255,0.5)', marginBottom: 8 }}>
{t('reporting_period')}: {new Date(report.methodology.oldest_review).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short' })} {new Date(report.methodology.newest_review).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short' })}
</p>
)}
<div style={{ marginBottom: 24 }} />
{/* Score Gauge */}
<div className="cover-score-gauge">
<ReputationScoreGauge score={score} locale={locale} />
</div>
{/* Feature Pills */}
<div className="cover-feature-pills">
<span className="pill dark">{report.review_count.toLocaleString()} {t('reviews_analyzed')}</span>
<span className="pill dark">{t('reputation_score')}: {Math.round(score)}</span>
<span className="pill dark">{report.critical_issues.length} {t('critical_issues_count')}</span>
</div>
{/* Stats Row */}
<div className="stats-row" style={{ marginTop: 32, maxWidth: 500, width: '100%' }}>
<div className="stat-item dark">
<div className="stat-value" style={{ color: 'white' }}>{report.current_rating.toFixed(1)}</div>
<div className="stat-label">{t('current_rating')}</div>
</div>
<div className="stat-item dark">
<div className="stat-value" style={{ color: scoreColor }}>{report.potential_rating.toFixed(1)}</div>
<div className="stat-label">{t('potential_rating')}</div>
</div>
<div className="stat-item dark">
<div className="stat-value" style={{ color: 'white' }}>{report.review_count.toLocaleString()}</div>
<div className="stat-label">{t('reviews_analyzed')}</div>
</div>
</div>
{/* Confidentiality */}
<p className="body-sm" style={{ color: 'rgba(255,255,255,0.3)', marginTop: 'auto', paddingTop: 24, fontSize: 10 }}>
{t('confidential_footer')}
</p>
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { getDomainColor } from '../styles/report-theme';
import { translatePrimitive, translateThemeLabel } from '../i18n/contentTranslations';
import { paginateItems } from '../utils/paginate';
interface CriticalIssuesProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number;
}
const COMPLEXITY_I18N: Record<string, string> = {
quick: 'quick_fix',
medium: 'moderate',
complex: 'complex',
};
export default function CriticalIssues({ report, locale = 'en', startPage = 6, totalPages = 9, sectionNumber = 5 }: CriticalIssuesProps) {
const { t } = useReportLocale(locale);
const issues = report.critical_issues;
if (issues.length === 0) return null;
const pages = paginateItems(issues, 2, 3);
return (
<>
{pages.map((pageItems, pageIdx) => (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="white">
{pageIdx === 0 ? (
<SectionHeader
number={sectionNumber}
title={t('critical_issues')}
subtitle={t('critical_issues_lead')}
/>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('critical_issues')} ({t('continued')})
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{pageItems.map((issue, idx) => {
const globalIdx = pageIdx === 0 ? idx : pages[0]!.length + (pageIdx - 1) * 3 + idx;
return (
<div key={globalIdx} className="issue-item">
<div className="issue-rank">{globalIdx + 1}</div>
<div className="issue-body">
<div className="issue-title">{translateThemeLabel(issue.title, locale)}</div>
<div className="issue-meta">
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(issue.domain) }} />
<span className="body-sm">{translatePrimitive(issue.primitive, locale)}</span>
</span>
<span className="body-sm" style={{ color: 'var(--ui-error)', fontWeight: 600 }}>
{issue.count} {t('complaints')}
</span>
{issue.score_cost != null && issue.score_cost > 0 && (
<span className="body-sm" style={{ color: '#B91C1C', fontWeight: 700, background: '#FEE2E2', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{issue.score_cost} {t('score_cost_pts')}
</span>
)}
<span className={`complexity-badge ${issue.complexity}`}>
{t(COMPLEXITY_I18N[issue.complexity] || 'moderate')}
</span>
</div>
{issue.description && (
<p className="body-sm text-secondary" style={{ marginBottom: 8 }}>{issue.description}</p>
)}
{issue.quotes.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="caption" style={{ marginBottom: 6 }}>{t('evidence')}</div>
{issue.quotes.slice(0, 2).map((quote, qi) => (
<div key={qi} className="evidence-quote red">
&ldquo;{quote}&rdquo;
</div>
))}
</div>
)}
{issue.solution && (
<div className="info-block blue">
<div className="info-label">{t('recommended_solution')}</div>
<p className="body-sm text-primary">{issue.solution}</p>
</div>
)}
</div>
</div>
);
})}
</div>
</ReportPage>
))}
</>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import DomainRadar from '../charts/DomainRadar';
import { getDomainColor } from '../styles/report-theme';
import { translateDomain } from '../i18n/contentTranslations';
interface DomainPerformanceProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function DomainPerformance({ report, locale = 'en', pageNumber = 5, totalPages = 9 }: DomainPerformanceProps) {
const { t } = useReportLocale(locale);
const domains = report.domains;
if (domains.length === 0) return null;
const sorted = [...domains].sort((a, b) => b.score - a.score);
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="gray">
<SectionHeader
number={4}
title={t('domain_performance')}
subtitle={t('domain_performance_lead')}
/>
{report.domain_overview && (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: '16px 20px', marginBottom: 20 }}>
<p className="body-sm" style={{ margin: 0, lineHeight: 1.6, color: 'var(--text-secondary)' }}>
{report.domain_overview}
</p>
</div>
)}
{/* Two-column: Radar + Score Bars */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 24 }}>
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20 }}>
<DomainRadar domains={domains} locale={locale} />
</div>
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20 }}>
<div className="score-bar-group">
{sorted.map((d) => (
<div key={d.domain} className="score-bar-row">
<span className="score-bar-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(d.domain) }} />
{translateDomain(d.label, locale)}
</span>
<div className="score-bar-track">
<div className="score-bar-fill" style={{ width: `${d.score}%`, backgroundColor: getDomainColor(d.domain) }} />
</div>
<span className="score-bar-value" style={{ color: getDomainColor(d.domain) }}>{d.score}%</span>
</div>
))}
</div>
</div>
</div>
{/* Domain Detail Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{domains.map((d) => (
<div key={d.domain} style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(d.domain) }} />
<span className="heading-md" style={{ fontSize: 13 }}>{translateDomain(d.label, locale)}</span>
</div>
<div className="stat-value text-primary" style={{ fontSize: 28 }}>{d.score}%</div>
<div className="body-sm" style={{ marginTop: 4 }}>
{d.weight}% {t('domain_weight_suffix')} &middot; {d.primitives.length} {t('domain_aspects_suffix')}
</div>
{d.narrative && (
<p className="body-sm" style={{ marginTop: 8, lineHeight: 1.5, color: 'var(--text-secondary)', fontSize: 12 }}>
{d.narrative}
</p>
)}
</div>
))}
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
interface EndPageProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function EndPage({
report,
locale = 'en',
pageNumber = 10,
totalPages = 12,
}: EndPageProps) {
const { t } = useReportLocale(locale);
const conclusion = report.conclusion;
if (!conclusion) return null;
const card = {
background: 'rgba(255,255,255,0.08)',
borderRadius: 8,
padding: 16,
marginBottom: 16,
} as const;
const divider = {
border: 'none',
borderTop: '1px solid rgba(255,255,255,0.12)',
margin: '16px 0',
} as const;
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="dark" showHeader={false}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', color: 'white' }}>
{/* Title */}
<div style={{ textAlign: 'center', marginBottom: 24, paddingTop: 16 }}>
<h2 className="display-md" style={{ color: 'white', marginBottom: 4 }}>
{t('end_page_title')}
</h2>
</div>
{/* Key Takeaways */}
{conclusion.takeaways.length > 0 && (
<div style={card}>
<h3 style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#F59E0B', marginBottom: 10 }}>
{t('key_takeaways')}
</h3>
<ol style={{ margin: 0, paddingLeft: 20 }}>
{conclusion.takeaways.map((item, i) => (
<li key={i} style={{ marginBottom: 6, fontWeight: 600, lineHeight: 1.6, fontSize: 13, color: 'rgba(255,255,255,0.9)' }}>
{item}
</li>
))}
</ol>
</div>
)}
{/* 90-Day Focus */}
{conclusion.ninety_day_focus && (
<div style={{ ...card, borderLeft: '3px solid #4285F4' }}>
<h4 style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#60A5FA', marginBottom: 6 }}>
{t('ninety_day_focus')}
</h4>
<p style={{ lineHeight: 1.6, fontSize: 12, color: 'rgba(255,255,255,0.8)', margin: 0 }}>
{conclusion.ninety_day_focus}
</p>
</div>
)}
{/* Review Cadence */}
{conclusion.review_cadence && (
<div style={{ marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.7)', marginBottom: 4 }}>
{t('review_cadence')}
</h4>
<p style={{ lineHeight: 1.5, fontSize: 12, color: 'rgba(255,255,255,0.6)', margin: 0 }}>
{conclusion.review_cadence}
</p>
</div>
)}
<hr style={divider} />
{/* Cost of Inaction */}
{conclusion.cost_of_inaction && (
<div style={{ ...card, borderLeft: '3px solid #F59E0B', background: 'rgba(245,158,11,0.1)' }}>
<h4 style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#FBBF24', marginBottom: 6 }}>
{t('cost_of_inaction')}
</h4>
<p style={{ lineHeight: 1.6, fontSize: 12, color: 'rgba(255,255,255,0.85)', margin: 0 }}>
{conclusion.cost_of_inaction}
</p>
</div>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
<hr style={divider} />
{/* Branding */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 24, marginBottom: 16 }}>
<div style={{ textAlign: 'center' }}>
<p style={{ fontWeight: 700, fontSize: 16, marginBottom: 2, color: 'white' }}>
whyrating<span style={{ color: '#F59E0B' }}>.com</span>
</p>
<p style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{t('reputation_intelligence')}</p>
<p style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginTop: 4 }}>{t('view_dashboard')}</p>
</div>
</div>
{/* Confidentiality */}
<p style={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)', fontSize: 10, marginBottom: 0 }}>
{t('confidential_footer')}
</p>
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { getScoreColor } from '../styles/report-theme';
import { translateLevel, translateTimeline } from '../i18n/contentTranslations';
const PILLAR_KEYS = ['rating_quality', 'sentiment_depth', 'volume', 'momentum', 'intensity'] as const;
const PILLAR_MAX: Record<string, number> = {
rating_quality: 30, sentiment_depth: 25, volume: 15, momentum: 15, intensity: 15,
};
interface ExecutiveSummaryProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function ExecutiveSummary({ report, locale = 'en', pageNumber = 2, totalPages = 9 }: ExecutiveSummaryProps) {
const { t } = useReportLocale(locale);
const scoreColor = getScoreColor(report.reputation_score);
const topActions = report.actions.slice(0, 3);
const breakdown = report.score_breakdown;
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="white">
<SectionHeader number={1} title={t('executive_summary')} subtitle={t('executive_summary_lead')} />
{/* Verdict */}
{report.verdict && (
<div className="insight-callout">
<p className="body-lg text-primary" style={{ fontWeight: 500 }}>{report.verdict}</p>
</div>
)}
{/* Score Breakdown */}
<div style={{ margin: '20px 0' }}>
<h3 className="caption" style={{ marginBottom: 14 }}>{t('score_breakdown')}</h3>
<div className="score-bar-group">
{PILLAR_KEYS.map((key) => {
const value = breakdown[key] ?? 0;
const max = PILLAR_MAX[key] || 25;
const pct = (value / max) * 100;
return (
<div key={key} className="score-bar-row">
<span className="score-bar-label">{t(`pillar_${key}`)}</span>
<div className="score-bar-track">
<div className="score-bar-fill" style={{ width: `${Math.min(pct, 100)}%`, backgroundColor: scoreColor }} />
</div>
<span className="score-bar-value" style={{ color: scoreColor }}>{value.toFixed(1)}<span className="text-tertiary" style={{ fontSize: 11 }}>/{max}</span></span>
</div>
);
})}
</div>
</div>
{/* Key Findings */}
{report.key_findings.length > 0 && (
<div style={{ margin: '20px 0' }}>
<h3 className="caption" style={{ marginBottom: 12 }}>{t('key_findings')}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{report.key_findings.map((finding, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div className="section-number" style={{ width: 28, height: 28, fontSize: 13, flexShrink: 0 }}>{i + 1}</div>
<p className="body-md text-primary">{finding}</p>
</div>
))}
</div>
</div>
)}
{/* Revenue Impact */}
{report.revenue_impact && (
<div className="alert-block">
<div className="alert-label">{t('revenue_impact')}</div>
<p className="body-md text-primary">{report.revenue_impact}</p>
</div>
)}
{/* Reputational Cost Summary */}
{report.critical_issues.some(ci => ci.score_cost != null && ci.score_cost > 0) && (() => {
const costIssues = report.critical_issues
.filter(ci => ci.score_cost != null && ci.score_cost > 0)
.sort((a, b) => (b.score_cost ?? 0) - (a.score_cost ?? 0))
.slice(0, 3);
const totalCost = costIssues.reduce((s, ci) => s + (ci.score_cost ?? 0), 0);
return (
<div style={{ marginTop: 16, padding: '12px 16px', background: '#FEF2F2', borderRadius: 8, borderLeft: '3px solid #DC2626' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="heading-md" style={{ color: '#B91C1C', fontSize: 13 }}>{t('score_cost')}</span>
<span style={{ color: '#B91C1C', fontWeight: 700, fontSize: 18 }}>{totalCost.toFixed(1)} pts</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{costIssues.map((ci, i) => (
<div key={i} className="body-sm" style={{ color: '#7F1D1D' }}>
<strong>{ci.score_cost?.toFixed(1)}</strong> {ci.title}
</div>
))}
</div>
</div>
);
})()}
{/* Top Actions Preview */}
{topActions.length > 0 && (
<div style={{ marginTop: 16 }}>
<h3 className="caption" style={{ marginBottom: 12 }}>{t('top_priority_actions')}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{topActions.map((action, i) => (
<div key={i} className="framework-item" style={{ padding: 14 }}>
<div className="framework-number" style={{ width: 28, height: 28, fontSize: 13 }}>{i + 1}</div>
<div className="framework-body">
<div className="heading-md" style={{ fontSize: 14 }}>{action.action}</div>
<div className="body-sm" style={{ marginTop: 4 }}>
{action.owner} &middot; {translateTimeline(action.timeline, locale)} &middot; <strong>{translateLevel(action.impact, locale)} {t('impact')}</strong>
</div>
</div>
</div>
))}
</div>
</div>
)}
</ReportPage>
);
}

View File

@@ -0,0 +1,177 @@
'use client';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
interface HowToReadProps {
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
const SCORE_BANDS_VISUAL = [
{ range: '039', color: '#ef4444', key: 'score_critical', width: 40 },
{ range: '4059', color: '#f97316', key: 'score_poor', width: 20 },
{ range: '6074', color: '#f59e0b', key: 'score_fair', width: 15 },
{ range: '7589', color: '#22c55e', key: 'score_good', width: 15 },
{ range: '90100', color: '#059669', key: 'score_excellent', width: 10 },
];
const VALENCE_MARKERS = [
{ symbol: '+', color: '#22c55e', bg: '#DCFCE7', key: 'how_to_read_valence_pos' },
{ symbol: '', color: '#ef4444', bg: '#FEE2E2', key: 'how_to_read_valence_neg' },
{ symbol: '0', color: '#9ca3af', bg: '#F3F4F6', key: 'how_to_read_valence_neu' },
{ symbol: '±', color: '#f59e0b', bg: '#FEF3C7', key: 'how_to_read_valence_mix' },
];
const DOMAINS = [
{ code: 'O', color: '#3b82f6', key: 'how_to_read_domain_o' },
{ code: 'P', color: '#22c55e', key: 'how_to_read_domain_p' },
{ code: 'J', color: '#f59e0b', key: 'how_to_read_domain_j' },
{ code: 'E', color: '#8b5cf6', key: 'how_to_read_domain_e' },
{ code: 'V', color: '#f43f5e', key: 'how_to_read_domain_v' },
];
const INTENSITY_LEVELS = [
{ level: 1, bars: 1, key: 'how_to_read_intensity_1' },
{ level: 2, bars: 2, key: 'how_to_read_intensity_2' },
{ level: 3, bars: 3, key: 'how_to_read_intensity_3' },
];
export default function HowToRead({
locale = 'en',
pageNumber = 3,
totalPages = 12,
}: HowToReadProps) {
const { t } = useReportLocale(locale);
const labelStyle = { fontSize: 13, fontWeight: 700, marginBottom: 8, color: 'var(--text-primary)' } as const;
const descStyle = { fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: 10 } as const;
const cardStyle = {
background: 'var(--surface-card)',
borderRadius: 'var(--radius-brand)',
padding: 16,
} as const;
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="gray">
<div style={{ paddingTop: 16 }}>
<h2 className="display-md" style={{ marginBottom: 4 }}>
{t('how_to_read')}
</h2>
<p className="body-md text-secondary" style={{ marginBottom: 24 }}>
{t('how_to_read_lead')}
</p>
{/* Score Spectrum */}
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 16, marginBottom: 16 }}>
<h3 style={labelStyle}>{t('how_to_read_score_title')}</h3>
<div style={{ display: 'flex', height: 28, borderRadius: 6, overflow: 'hidden', marginBottom: 6 }}>
{SCORE_BANDS_VISUAL.map(band => (
<div key={band.range} style={{
width: `${band.width}%`,
backgroundColor: band.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontSize: 9, fontWeight: 600,
}}>
<span>{t(band.key)}</span>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: '#9ca3af', marginBottom: 8 }}>
<span>0</span>
<span>40</span>
<span>60</span>
<span>75</span>
<span>90</span>
<span>100</span>
</div>
<p style={descStyle}>{t('how_to_read_score_desc')}</p>
</div>
{/* 2x2 Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{/* Card 1: Sentiment Markers */}
<div style={cardStyle}>
<h3 style={labelStyle}>{t('how_to_read_valence_title')}</h3>
<p style={descStyle}>{t('how_to_read_valence_desc')}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{VALENCE_MARKERS.map(v => (
<div key={v.symbol} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 4,
background: v.bg, color: v.color, fontWeight: 700, fontSize: 14,
border: `1px solid ${v.color}`,
}}>
{v.symbol}
</span>
<span className="body-sm text-secondary">{t(v.key)}</span>
</div>
))}
</div>
</div>
{/* Card 2: Experience Domains */}
<div style={cardStyle}>
<h3 style={labelStyle}>{t('how_to_read_domains_title')}</h3>
<p style={descStyle}>{t('how_to_read_domains_desc')}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{DOMAINS.map(d => (
<div key={d.code} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 12,
background: d.color, color: 'white', fontWeight: 700, fontSize: 11,
}}>
{d.code}
</span>
<span className="body-sm text-secondary">{t(d.key)}</span>
</div>
))}
</div>
</div>
{/* Card 3: Intensity Levels */}
<div style={cardStyle}>
<h3 style={labelStyle}>{t('how_to_read_intensity_title')}</h3>
<p style={descStyle}>{t('how_to_read_intensity_desc')}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{INTENSITY_LEVELS.map(il => (
<div key={il.level} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 2, width: 24, justifyContent: 'center' }}>
{Array.from({ length: 3 }, (_, i) => (
<div key={i} style={{
width: 5, height: 14, borderRadius: 2,
background: i < il.bars ? '#4285F4' : '#E2E8F0',
}} />
))}
</div>
<span className="body-sm text-secondary">{t(il.key)}</span>
</div>
))}
</div>
</div>
{/* Card 4: Reading Tips */}
<div style={cardStyle}>
<h3 style={labelStyle}>{t('how_to_read_tips_title')}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 11, color: 'var(--ui-primary, #4285F4)', fontWeight: 700, flexShrink: 0 }}>
{i}.
</span>
<span className="body-sm text-secondary" style={{ lineHeight: 1.4 }}>
{t(`how_to_read_tip_${i}`)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import RatingTrend from '../charts/RatingTrend';
import SentimentDonut from '../charts/SentimentDonut';
import { getScoreColor } from '../styles/report-theme';
function getRatingBarColor(star: number): string {
if (star === 5) return '#22c55e';
if (star === 4) return '#84cc16';
if (star === 3) return '#f59e0b';
if (star === 2) return '#f97316';
return '#ef4444';
}
interface RatingDashboardProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function RatingDashboard({ report, locale = 'en', pageNumber = 3, totalPages = 9 }: RatingDashboardProps) {
const { t } = useReportLocale(locale);
const dist = report.rating_distribution;
const distEntries = Object.entries(dist)
.map(([k, v]) => ({ star: parseInt(k), count: v }))
.sort((a, b) => b.star - a.star);
const totalReviews = distEntries.reduce((sum, d) => sum + d.count, 0);
const maxCount = Math.max(...distEntries.map(d => d.count), 1);
const scoreColor = getScoreColor(report.reputation_score);
const sentimentData = report.charts?.sentiment_donut || [];
const positive = sentimentData.find(d => d.label === 'Positive')?.value || 0;
const negative = sentimentData.find(d => d.label === 'Negative')?.value || 0;
const neutral = sentimentData.find(d => d.label === 'Neutral')?.value || 0;
const mixed = sentimentData.find(d => d.label === 'Mixed')?.value || 0;
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="gray">
<SectionHeader number={2} title={t('rating_dashboard')} subtitle={t('rating_dashboard_lead')} />
{/* Stats Row */}
<div className="stats-row">
<div className="stat-item">
<div className="stat-value text-primary">{report.current_rating.toFixed(1)}</div>
<div className="stat-label">{t('current_rating')}</div>
</div>
<div className="stat-item">
<div className="stat-value" style={{ color: scoreColor }}>{report.potential_rating.toFixed(1)}</div>
<div className="stat-label">{t('potential_rating')}</div>
</div>
<div className="stat-item">
<div className="stat-value text-primary">{totalReviews.toLocaleString()}</div>
<div className="stat-label">{t('reviews_analyzed')}</div>
</div>
<div className="stat-item">
<div className="stat-value" style={{ color: scoreColor }}>{Math.round(report.reputation_score)}</div>
<div className="stat-label">{t('reputation_score')}</div>
</div>
</div>
{/* Rating Narrative */}
{report.rating_narrative && (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: '16px 20px', marginTop: 16 }}>
<p className="body-sm" style={{ margin: 0, lineHeight: 1.6, color: 'var(--text-secondary)' }}>
{report.rating_narrative}
</p>
</div>
)}
{/* Two-column layout: Distribution + Sentiment */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 20 }}>
{/* Rating Distribution */}
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20 }}>
<h3 className="caption" style={{ marginBottom: 14 }}>{t('distribution')}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{distEntries.map((item) => {
const pct = totalReviews > 0 ? (item.count / totalReviews) * 100 : 0;
return (
<div key={item.star} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="body-sm" style={{ width: 28, fontWeight: 600 }}>{item.star}</span>
<div style={{ flex: 1, height: 16, background: 'var(--surface-muted)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(item.count / maxCount) * 100}%`, backgroundColor: getRatingBarColor(item.star), borderRadius: 8 }} />
</div>
<span className="body-sm" style={{ width: 40, textAlign: 'right', fontWeight: 500 }}>{pct.toFixed(0)}%</span>
</div>
);
})}
</div>
</div>
{/* Sentiment Donut */}
{(positive + negative + neutral + mixed) > 0 && (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20 }}>
<h3 className="caption" style={{ marginBottom: 14 }}>{t('sentiment_distribution')}</h3>
<SentimentDonut positive={positive} negative={negative} neutral={neutral} mixed={mixed} />
</div>
)}
</div>
{/* Rating Trend */}
{report.charts?.rating_trend && report.charts.rating_trend.length > 0 && (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20, marginTop: 20 }}>
<h3 className="caption" style={{ marginBottom: 14 }}>{t('rating_trend')}</h3>
<RatingTrend data={report.charts.rating_trend} />
</div>
)}
</ReportPage>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import type { ReportSynthesis, ReviewEvidence as ReviewEvidenceType, ReviewClassification } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { getValenceColor, getDomainColor } from '../styles/report-theme';
import { translatePrimitive } from '../i18n/contentTranslations';
import { paginateItems } from '../utils/paginate';
interface ReviewEvidenceProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number | string;
}
/** Build highlighted review text with anchor spans color-coded by valence. */
function HighlightedReviewText({ text, classifications }: { text: string; classifications: ReviewClassification[] }) {
// Sort anchors by start position (descending) to avoid offset shift
const anchors = classifications
.filter(c => c.anchor_start != null && c.anchor_end != null)
.sort((a, b) => (a.anchor_start ?? 0) - (b.anchor_start ?? 0));
if (anchors.length === 0) {
return <span className="body-sm text-secondary">{text}</span>;
}
const parts: React.ReactNode[] = [];
let cursor = 0;
for (const anchor of anchors) {
const start = anchor.anchor_start!;
const end = anchor.anchor_end!;
// Skip overlapping or invalid anchors
if (start < cursor || start >= text.length) continue;
// Text before anchor
if (cursor < start) {
parts.push(<span key={`pre-${start}`} className="body-sm text-secondary">{text.slice(cursor, start)}</span>);
}
// Anchor span
const valenceColorMap: Record<string, string> = {
'+': '#DCFCE7',
'-': '#FEE2E2',
'±': '#FEF3C7',
'0': '#F3F4F6',
};
const bgColor = valenceColorMap[anchor.valence] || '#F3F4F6';
const borderColor = getValenceColor(anchor.valence);
parts.push(
<span
key={`anchor-${start}`}
title={`${anchor.primitive} (${anchor.valence})`}
style={{
backgroundColor: bgColor,
borderBottom: `2px solid ${borderColor}`,
borderRadius: 2,
padding: '0 2px',
}}
>
<span className="body-sm" style={{ fontWeight: 500 }}>{text.slice(start, Math.min(end, text.length))}</span>
</span>
);
cursor = Math.min(end, text.length);
}
// Remaining text
if (cursor < text.length) {
parts.push(<span key="tail" className="body-sm text-secondary">{text.slice(cursor)}</span>);
}
return <>{parts}</>;
}
function StarRating({ rating }: { rating: number }) {
return (
<span style={{ fontSize: 12, letterSpacing: 1 }}>
{Array.from({ length: 5 }, (_, i) => (
<span key={i} style={{ color: i < rating ? '#FBBC05' : '#D1D5DB' }}></span>
))}
</span>
);
}
// 3 reviews per first page, 4 per subsequent
const FIRST_PAGE_LIMIT = 3;
const NEXT_PAGE_LIMIT = 4;
export default function ReviewEvidence({
report,
locale = 'en',
startPage = 10,
totalPages = 12,
sectionNumber = 'A',
}: ReviewEvidenceProps) {
const { t } = useReportLocale(locale);
const reviews = report.review_evidence;
if (!reviews?.length) return null;
// Only show reviews with text
const filtered = reviews.filter(r => r.full_text && r.full_text.length > 0);
if (filtered.length === 0) return null;
const pages = paginateItems(filtered, FIRST_PAGE_LIMIT, NEXT_PAGE_LIMIT);
return (
<>
{pages.map((pageItems, pageIdx) => (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="white">
{pageIdx === 0 ? (
<SectionHeader
number={sectionNumber}
title={t('appendix_review_evidence')}
subtitle={t('appendix_lead')}
/>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('appendix_review_evidence')} ({t('continued')})
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{pageItems.map((review, idx) => (
<ReviewCard
key={review.review_id}
review={review}
locale={locale}
t={t}
/>
))}
</div>
</ReportPage>
))}
</>
);
}
function ReviewCard({
review,
locale,
t,
}: {
review: ReviewEvidenceType;
locale: string;
t: (key: string) => string;
}) {
return (
<div style={{
padding: '12px 0',
borderBottom: '1px solid var(--border-light)',
}}>
{/* Header: author + rating + date */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="heading-md" style={{ fontSize: 13 }}>
{review.author || t('anonymous')}
</span>
{review.rating && <StarRating rating={review.rating} />}
{review.date && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{isNaN(Date.parse(review.date)) ? review.date : new Date(review.date).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' })}
</span>
)}
</div>
</div>
{/* Highlighted review text */}
<div style={{ marginBottom: 8, lineHeight: 1.6 }}>
<HighlightedReviewText
text={review.full_text}
classifications={review.classifications}
/>
</div>
{/* Classification tags */}
{review.classifications.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{review.classifications.map((cls, i) => {
const color = getValenceColor(cls.valence);
const bgMap: Record<string, string> = { '+': '#DCFCE7', '-': '#FEE2E2', '±': '#FEF3C7', '0': '#F3F4F6' };
const textMap: Record<string, string> = { '+': '#166534', '-': '#991B1B', '±': '#92400E', '0': '#6B7280' };
const bg = bgMap[cls.valence] || '#F3F4F6';
const textColor = textMap[cls.valence] || '#6B7280';
return (
<span
key={i}
style={{
fontSize: 10,
background: bg,
color: textColor,
borderLeft: `3px solid ${color}`,
paddingLeft: 6,
padding: '2px 8px 2px 6px',
borderRadius: 4,
fontWeight: 500,
}}
>
{translatePrimitive(cls.primitive, locale)} <span style={{ fontWeight: 700 }}>{cls.valence}</span>
</span>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,378 @@
'use client';
import type { ReportSynthesis, StaffMember, StaffLeaderboardResolved, StaffIndividual, StaffGroup } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { paginateItems } from '../utils/paginate';
interface StaffLeaderboardProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number;
}
const RANK_COLORS: Record<number, { bg: string; color: string }> = {
1: { bg: '#FEF3C7', color: '#92400E' },
2: { bg: '#F1F5F9', color: '#475569' },
3: { bg: '#FED7AA', color: '#9A3412' },
};
// First page has section header + callout -> fewer rows
const FIRST_PAGE_ROWS = 6;
const NEXT_PAGE_ROWS = 12;
function isResolvedStaff(staff: any): staff is StaffLeaderboardResolved {
return staff && typeof staff === 'object' && !Array.isArray(staff) && 'individuals' in staff;
}
export default function StaffLeaderboard({ report, locale = 'en', startPage = 8, totalPages = 10, sectionNumber = 7 }: StaffLeaderboardProps) {
const { t } = useReportLocale(locale);
const staff = report.staff_leaderboard;
if (!staff) return null;
if (isResolvedStaff(staff)) {
return (
<ResolvedStaffLeaderboard
resolved={staff}
locale={locale}
startPage={startPage}
totalPages={totalPages}
sectionNumber={sectionNumber}
t={t}
/>
);
}
// Legacy array format
if (!Array.isArray(staff) || staff.length === 0) return null;
return (
<LegacyStaffLeaderboard
staff={staff}
locale={locale}
startPage={startPage}
totalPages={totalPages}
sectionNumber={sectionNumber}
t={t}
/>
);
}
// ---------------------------------------------------------------------------
// Resolved (v2.1.0+) format
// ---------------------------------------------------------------------------
function ResolvedStaffLeaderboard({
resolved,
locale,
startPage,
totalPages,
sectionNumber,
t,
}: {
resolved: StaffLeaderboardResolved;
locale: string;
startPage: number;
totalPages: number;
sectionNumber: number;
t: (key: string) => string;
}) {
const { individuals, groups } = resolved;
if (individuals.length === 0 && groups.length === 0) return null;
const topPerformer = individuals[0] || null;
return (
<ReportPage pageNumber={startPage} totalPages={totalPages} background="white">
<SectionHeader
number={sectionNumber}
title={t('staff_leaderboard')}
subtitle={t('staff_leaderboard_lead')}
/>
{/* Top Performer Callout */}
{topPerformer && topPerformer.positive_quotes.length > 0 && (
<div className="insight-callout green" style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="heading-md" style={{ color: 'var(--ui-success)' }}>
{t('staff_top_performer')}: {topPerformer.canonical_name}
</span>
</div>
<div className="evidence-quote green" style={{ margin: 0 }}>
&ldquo;{topPerformer.positive_quotes[0]}&rdquo;
</div>
</div>
)}
{/* Individuals Table */}
{individuals.length > 0 && (
<>
<h3 className="caption" style={{ marginBottom: 8 }}>{t('staff_individuals')}</h3>
<ResolvedTable members={individuals} t={t} showRole />
</>
)}
{/* Groups Table */}
{groups.length > 0 && (
<>
<h3 className="caption" style={{ marginTop: 20, marginBottom: 8 }}>{t('staff_groups')}</h3>
<ResolvedTable members={groups} t={t} showRole={false} />
</>
)}
{/* Observations */}
{resolved.observations && (
<div className="insight-callout" style={{ marginTop: 16, background: '#F0F9FF', borderLeftColor: '#3B82F6' }}>
<p className="body-sm" style={{ color: 'var(--text-secondary)', margin: 0 }}>
{resolved.observations}
</p>
</div>
)}
{/* Disclaimer */}
<div className="insight-callout" style={{ marginTop: 16, background: '#FFF7ED', borderLeftColor: '#F59E0B' }}>
<p className="body-sm" style={{ color: 'var(--text-secondary)', margin: 0 }}>
<strong style={{ color: '#92400E' }}>&#9888;</strong> {t('staff_disclaimer')}
</p>
</div>
</ReportPage>
);
}
function ResolvedTable({
members,
t,
showRole,
}: {
members: (StaffIndividual | StaffGroup)[];
t: (key: string) => string;
showRole: boolean;
}) {
return (
<table className="scoring-table">
<thead>
<tr>
<th style={{ width: 60 }}>{t('staff_rank')}</th>
<th>{t('staff_name')}</th>
{showRole && <th style={{ width: 100 }}>{t('staff_role')}</th>}
<th style={{ width: 90, textAlign: 'center' }}>{t('staff_mentions')}</th>
<th style={{ width: 100, textAlign: 'center' }}>{t('staff_sentiment')}</th>
<th style={{ width: 80, textAlign: 'center' }}>{t('staff_positive')}</th>
<th style={{ width: 80, textAlign: 'center' }}>{t('staff_negative')}</th>
</tr>
</thead>
<tbody>
{members.map((member, i) => {
const rank = i + 1;
const rankStyle = RANK_COLORS[rank];
const sentimentScore = member.sentiment_score ?? 0;
const role = showRole && 'role_inferred' in member ? (member as StaffIndividual).role_inferred : null;
return (
<tr key={member.canonical_name}>
<td style={{ textAlign: 'center' }}>
{rankStyle ? (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: rankStyle.bg, color: rankStyle.color,
fontWeight: 700, fontSize: 13,
}}>
{rank}
</span>
) : (
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>{rank}</span>
)}
</td>
<td style={{ fontWeight: 600 }}>{member.canonical_name}</td>
{showRole && <td style={{ color: 'var(--text-secondary)', fontSize: 12 }}>{role || '\u2014'}</td>}
<td style={{ textAlign: 'center', fontWeight: 600 }}>{member.total_mentions}</td>
<td style={{ textAlign: 'center' }}>
<span className={`score-badge ${sentimentScore >= 70 ? 'green' : 'amber'}`}>
{sentimentScore}%
</span>
</td>
<td style={{ textAlign: 'center', color: 'var(--ui-success)', fontWeight: 600 }}>
{member.positive}
</td>
<td style={{ textAlign: 'center', color: 'var(--ui-error)', fontWeight: 600 }}>
{member.negative}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
// ---------------------------------------------------------------------------
// Legacy (v2.0.0) array format
// ---------------------------------------------------------------------------
function LegacyStaffLeaderboard({
staff,
locale,
startPage,
totalPages,
sectionNumber,
t,
}: {
staff: StaffMember[];
locale: string;
startPage: number;
totalPages: number;
sectionNumber: number;
t: (key: string) => string;
}) {
const topPerformer = staff[0];
const pages = paginateItems(staff, FIRST_PAGE_ROWS, NEXT_PAGE_ROWS);
return (
<>
{pages.map((pageStaff, pageIdx) => {
// Compute global offset for rank numbering
let globalOffset = 0;
for (let p = 0; p < pageIdx; p++) globalOffset += pages[p]!.length;
const isLastPage = pageIdx === pages.length - 1;
return (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="white">
{pageIdx === 0 ? (
<>
<SectionHeader
number={sectionNumber}
title={t('staff_leaderboard')}
subtitle={t('staff_leaderboard_lead')}
/>
{/* Top Performer Callout */}
{topPerformer && topPerformer.positive_quotes && topPerformer.positive_quotes.length > 0 && (
<div className="insight-callout green" style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="heading-md" style={{ color: 'var(--ui-success)' }}>
{t('staff_top_performer')}: {topPerformer.name}
</span>
</div>
<div className="evidence-quote green" style={{ margin: 0 }}>
&ldquo;{topPerformer.positive_quotes[0]}&rdquo;
</div>
</div>
)}
</>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('staff_leaderboard')} ({t('continued')})
</div>
)}
{/* Staff Table */}
<LegacyStaffTable staff={pageStaff} globalOffset={globalOffset} t={t} />
{/* Evidence Quotes -- only on the last page */}
{isLastPage && (
<>
<StaffEvidence staff={staff.slice(0, 3)} t={t} />
<div className="insight-callout" style={{ marginTop: 16, background: '#FFF7ED', borderLeftColor: '#F59E0B' }}>
<p className="body-sm" style={{ color: 'var(--text-secondary)', margin: 0 }}>
<strong style={{ color: '#92400E' }}>&#9888;</strong> {t('staff_disclaimer')}
</p>
</div>
</>
)}
</ReportPage>
);
})}
</>
);
}
function LegacyStaffTable({ staff, globalOffset, t }: { staff: StaffMember[]; globalOffset: number; t: (key: string) => string }) {
return (
<table className="scoring-table">
<thead>
<tr>
<th style={{ width: 60 }}>{t('staff_rank')}</th>
<th>{t('staff_name')}</th>
<th style={{ width: 90, textAlign: 'center' }}>{t('staff_mentions')}</th>
<th style={{ width: 100, textAlign: 'center' }}>{t('staff_sentiment')}</th>
<th style={{ width: 80, textAlign: 'center' }}>{t('staff_positive')}</th>
<th style={{ width: 80, textAlign: 'center' }}>{t('staff_negative')}</th>
</tr>
</thead>
<tbody>
{staff.map((member, i) => {
const rank = globalOffset + i + 1;
const rankStyle = RANK_COLORS[rank];
const sentimentScore = member.sentiment_score ?? 0;
return (
<tr key={member.name}>
<td style={{ textAlign: 'center' }}>
{rankStyle ? (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: rankStyle.bg, color: rankStyle.color,
fontWeight: 700, fontSize: 13,
}}>
{rank}
</span>
) : (
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>{rank}</span>
)}
</td>
<td style={{ fontWeight: 600 }}>{member.name}</td>
<td style={{ textAlign: 'center', fontWeight: 600 }}>{member.total_mentions}</td>
<td style={{ textAlign: 'center' }}>
<span className={`score-badge ${sentimentScore >= 70 ? 'green' : 'amber'}`}>
{sentimentScore}%
</span>
</td>
<td style={{ textAlign: 'center', color: 'var(--ui-success)', fontWeight: 600 }}>
{member.positive}
</td>
<td style={{ textAlign: 'center', color: 'var(--ui-error)', fontWeight: 600 }}>
{member.negative}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function StaffEvidence({ staff, t }: { staff: StaffMember[]; t: (key: string) => string }) {
const hasQuotes = staff.some(m => m.positive_quotes && m.positive_quotes.length > 0);
if (!hasQuotes) return null;
return (
<div style={{ marginTop: 24 }}>
<div className="caption" style={{ marginBottom: 12 }}>
{t('evidence')}
</div>
{staff.map((member) => {
const quotes = member.positive_quotes?.slice(0, 1) || [];
if (quotes.length === 0) return null;
return (
<div key={member.name} style={{ marginBottom: 8 }}>
<span className="body-sm" style={{ fontWeight: 600, color: 'var(--text-primary)', marginRight: 6 }}>
{member.name}:
</span>
{quotes.map((q, qi) => (
<div key={qi} className="evidence-quote green">
&ldquo;{q}&rdquo;
</div>
))}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import { getDomainColor } from '../styles/report-theme';
import { translatePrimitive, translateThemeLabel } from '../i18n/contentTranslations';
import { paginateItems } from '../utils/paginate';
interface StrengthsToProtectProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number;
}
export default function StrengthsToProtect({ report, locale = 'en', startPage = 7, totalPages = 9, sectionNumber = 6 }: StrengthsToProtectProps) {
const { t } = useReportLocale(locale);
const strengths = report.strengths;
if (strengths.length === 0) return null;
const pages = paginateItems(strengths, 2, 3);
return (
<>
{pages.map((pageItems, pageIdx) => (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="gray">
{pageIdx === 0 ? (
<SectionHeader
number={sectionNumber}
title={t('protect_strengths')}
subtitle={t('protect_strengths_lead')}
/>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('protect_strengths')} ({t('continued')})
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{pageItems.map((strength, idx) => {
const globalIdx = pageIdx === 0 ? idx : pages[0]!.length + (pageIdx - 1) * 3 + idx;
return (
<div key={globalIdx} className="framework-item">
<div className="framework-number green">{globalIdx + 1}</div>
<div className="framework-body">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div className="issue-title">{translateThemeLabel(strength.title, locale)}</div>
<span className="pill light" style={{ fontSize: 11 }}>
{strength.count} {t('mentions')}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(strength.domain) }} />
<span className="body-sm">{translatePrimitive(strength.primitive, locale)}</span>
</div>
{strength.description && (
<p className="body-sm text-secondary" style={{ marginBottom: 8 }}>{strength.description}</p>
)}
{strength.quotes.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="caption" style={{ marginBottom: 6 }}>{t('customer_voices')}</div>
{strength.quotes.slice(0, 2).map((quote, qi) => (
<div key={qi} className="evidence-quote green">
&ldquo;{quote}&rdquo;
</div>
))}
</div>
)}
{strength.marketing_angle && (
<div className="info-block green">
<div className="info-label" style={{ color: 'var(--ui-success)' }}>{t('marketing_angle')}</div>
<p className="body-sm text-primary">{strength.marketing_angle}</p>
</div>
)}
</div>
</div>
);
})}
</div>
</ReportPage>
))}
</>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
interface TocEntry {
number: string | number;
title: string;
page: number;
anchorId?: string;
description?: string;
}
interface TableOfContentsProps {
entries: TocEntry[];
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function TableOfContents({
entries,
locale = 'en',
pageNumber = 2,
totalPages = 12,
}: TableOfContentsProps) {
const { t } = useReportLocale(locale);
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="white">
<div style={{ paddingTop: 16 }}>
<h2 className="display-md" style={{ marginBottom: 24 }}>
{t('table_of_contents')}
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{entries.map((entry, i) => (
<a
key={i}
href={entry.anchorId ? `#${entry.anchorId}` : undefined}
onClick={(e) => {
if (!entry.anchorId) return;
e.preventDefault();
document.getElementById(entry.anchorId)?.scrollIntoView({ behavior: 'smooth' });
}}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '10px 0',
textDecoration: 'none',
color: 'inherit',
cursor: entry.anchorId ? 'pointer' : 'default',
}}
>
{/* Section number badge */}
<div style={{
width: 28,
height: 28,
borderRadius: 14,
background: typeof entry.number === 'string' ? 'var(--text-secondary, #64748B)' : 'var(--ui-primary, #4285F4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}>
{entry.number}
</div>
{/* Title + Description */}
<div style={{ marginLeft: 12, flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span className="body-md" style={{ fontWeight: 500, flexShrink: 0 }}>
{entry.title}
</span>
{/* Dot leader */}
<span style={{
flex: 1,
borderBottom: '1px dotted var(--text-tertiary, #94A3B8)',
margin: '0 8px',
minWidth: 20,
alignSelf: 'flex-end',
marginBottom: 4,
}} />
{/* Page number */}
<span className="body-sm text-secondary" style={{ fontWeight: 600, flexShrink: 0 }}>
{entry.page}
</span>
</div>
{entry.description && (
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-tertiary, #94A3B8)', lineHeight: 1.3, marginTop: 1 }}>
{entry.description}
</span>
)}
</div>
</a>
))}
</div>
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import ThemeMatrix from '../charts/ThemeMatrix';
import IntensityHeatmap from '../charts/IntensityHeatmap';
import MomentumDual from '../charts/MomentumDual';
import { getDomainColor } from '../styles/report-theme';
import { translateThemeLabel } from '../i18n/contentTranslations';
interface ThemeAnalysisProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
}
export default function ThemeAnalysis({ report, locale = 'en', pageNumber = 4, totalPages = 9 }: ThemeAnalysisProps) {
const { t } = useReportLocale(locale);
const themes = report.themes;
if (themes.length === 0) return null;
const negativeThemes = [...themes]
.filter(t => t.valence.negative > 0)
.sort((a, b) => b.valence.negative * b.weight - a.valence.negative * a.weight)
.slice(0, 4);
const positiveThemes = [...themes]
.filter(t => t.valence.positive > 0)
.sort((a, b) => b.valence.positive * b.weight - a.valence.positive * a.weight)
.slice(0, 4);
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="white">
<SectionHeader
number={3}
title={t('theme_analysis')}
subtitle={`${themes.length} ${t('theme_analysis_lead')}`}
/>
{/* Theme Narrative */}
{report.themes_narrative && (
<p className="body-sm" style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--text-secondary)' }}>
{report.themes_narrative}
</p>
)}
{/* Theme Matrix Chart */}
<div style={{ marginBottom: 20 }}>
<h3 className="caption" style={{ marginBottom: 10 }}>{t('frequency_vs_sentiment')}</h3>
{report.matrix_narrative && (
<p className="body-sm" style={{ marginBottom: 10, lineHeight: 1.5, color: 'var(--text-secondary)', fontSize: 12 }}>
{report.matrix_narrative}
</p>
)}
<ThemeMatrix themes={themes} locale={locale} />
</div>
{/* Two-column: Pain Points + What Customers Love */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 16 }}>
{/* Negative */}
{negativeThemes.length > 0 && (
<div>
<h3 className="caption" style={{ color: 'var(--ui-error)', marginBottom: 10 }}>{t('pain_points')}</h3>
{negativeThemes.map((theme) => (
<div key={theme.primitive} style={{ display: 'flex', gap: 10, padding: '10px 12px', background: '#FEF2F2', borderRadius: 8, marginBottom: 8 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(theme.domain), marginTop: 5 }} />
<div style={{ flex: 1 }}>
<div className="heading-md" style={{ fontSize: 13 }}>{translateThemeLabel(theme.label, locale)}</div>
<div className="body-sm" style={{ color: 'var(--ui-error)', fontWeight: 600, fontSize: 11 }}>
{theme.valence.negative} {t('complaints')}
</div>
{theme.top_quotes.negative.length > 0 && (
<div className="evidence-quote red" style={{ fontSize: 11, margin: '6px 0 0', padding: '4px 10px' }}>
&ldquo;{theme.top_quotes.negative[0]}&rdquo;
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Positive */}
{positiveThemes.length > 0 && (
<div>
<h3 className="caption" style={{ color: 'var(--ui-success)', marginBottom: 10 }}>{t('what_customers_love')}</h3>
{positiveThemes.map((theme) => (
<div key={theme.primitive} style={{ display: 'flex', gap: 10, padding: '10px 12px', background: '#F0FDF4', borderRadius: 8, marginBottom: 8 }}>
<span className="domain-dot" style={{ backgroundColor: getDomainColor(theme.domain), marginTop: 5 }} />
<div style={{ flex: 1 }}>
<div className="heading-md" style={{ fontSize: 13 }}>{translateThemeLabel(theme.label, locale)}</div>
<div className="body-sm" style={{ color: 'var(--ui-success)', fontWeight: 600, fontSize: 11 }}>
{theme.valence.positive} {t('mentions')}
</div>
{theme.top_quotes.positive.length > 0 && (
<div className="evidence-quote green" style={{ fontSize: 11, margin: '6px 0 0', padding: '4px 10px' }}>
&ldquo;{theme.top_quotes.positive[0]}&rdquo;
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</ReportPage>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
interface TrackingFrameworkProps {
report: ReportSynthesis;
locale?: ReportLocale;
pageNumber?: number;
totalPages?: number;
sectionNumber?: number;
}
export default function TrackingFramework({ report, locale = 'en', pageNumber = 9, totalPages = 9, sectionNumber = 8 }: TrackingFrameworkProps) {
const { t } = useReportLocale(locale);
const kpis = report.kpis;
if (kpis.length === 0) return null;
return (
<ReportPage pageNumber={pageNumber} totalPages={totalPages} background="gray">
<SectionHeader
number={sectionNumber}
title={t('tracking_framework')}
subtitle={t('tracking_framework_lead')}
/>
<table className="scoring-table">
<thead>
<tr>
<th>{t('metric')}</th>
<th>{t('current')}</th>
<th>{t('target_30d')}</th>
<th>{t('target_90d')}</th>
</tr>
</thead>
<tbody>
{kpis.map((kpi, i) => (
<tr key={i}>
<td>
<span className="heading-md" style={{ fontSize: 13 }}>{kpi.metric}</span>
</td>
<td>
<span className="body-md">{kpi.current}</span>
</td>
<td>
<span className="score-badge amber">{kpi.target_30d}</span>
</td>
<td>
<span className="score-badge green">{kpi.target_90d}</span>
</td>
</tr>
))}
</tbody>
</table>
</ReportPage>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import type { ReportSynthesis } from '../types';
import type { ReportLocale } from '../i18n/useReportLocale';
import { useReportLocale } from '../i18n/useReportLocale';
import ReportPage from '../components/ReportPage';
import SectionHeader from '../components/SectionHeader';
import QuarterlyRatingChart from '../charts/QuarterlyRatingChart';
import DomainSentimentTrend from '../charts/DomainSentimentTrend';
import SeasonalPatternChart from '../charts/SeasonalPatternChart';
import { paginateItems } from '../utils/paginate';
interface TrendsTimelineProps {
report: ReportSynthesis;
locale?: ReportLocale;
startPage?: number;
totalPages?: number;
sectionNumber?: number;
}
interface ChartEntry {
key: string;
render: () => React.ReactNode;
}
export default function TrendsTimeline({
report,
locale = 'en',
startPage = 6,
totalPages = 9,
sectionNumber = 5,
}: TrendsTimelineProps) {
const { t } = useReportLocale(locale);
const quarterlyRating = report.charts?.quarterly_rating ?? [];
const domainSentiment = report.charts?.quarterly_domain_sentiment ?? [];
const seasonal = report.charts?.seasonal_pattern ?? [];
// Only render if we have meaningful data
if (quarterlyRating.length < 2 && domainSentiment.length < 2) return null;
// Build ordered list of available charts
const charts: ChartEntry[] = [];
if (quarterlyRating.length > 1) {
charts.push({
key: 'quarterly_rating',
render: () => (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20, marginBottom: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 12 }}>
{t('quarterly_rating_evolution')}
</h3>
<QuarterlyRatingChart data={quarterlyRating} />
{report.rating_evolution_narrative && (
<p className="body-sm" style={{ margin: '8px 0 0', lineHeight: 1.5, color: 'var(--text-secondary)', fontSize: 12 }}>
{report.rating_evolution_narrative}
</p>
)}
</div>
),
});
}
if (domainSentiment.length > 1) {
charts.push({
key: 'domain_sentiment',
render: () => (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20, marginBottom: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 12 }}>
{t('domain_sentiment_trend')}
</h3>
<DomainSentimentTrend data={domainSentiment} />
{report.domain_sentiment_narrative && (
<p className="body-sm" style={{ margin: '8px 0 0', lineHeight: 1.5, color: 'var(--text-secondary)', fontSize: 12 }}>
{report.domain_sentiment_narrative}
</p>
)}
</div>
),
});
}
if (seasonal.length > 1) {
charts.push({
key: 'seasonal',
render: () => (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: 20, marginBottom: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 12 }}>
{t('seasonal_pattern')}
</h3>
<SeasonalPatternChart data={seasonal} />
{report.seasonal_narrative && (
<p className="body-sm" style={{ margin: '8px 0 0', lineHeight: 1.5, color: 'var(--text-secondary)', fontSize: 12 }}>
{report.seasonal_narrative}
</p>
)}
</div>
),
});
}
// With narrative: 1 chart on first page (header + narrative + chart), 2 on continuation
// Without narrative: 2 charts on first page (header + 2 charts), 3 on continuation
const hasNarrative = !!report.trends_narrative;
const pages = paginateItems(charts, hasNarrative ? 1 : 2, hasNarrative ? 2 : 3);
return (
<>
{pages.map((pageCharts, pageIdx) => (
<ReportPage key={pageIdx} pageNumber={startPage + pageIdx} totalPages={totalPages} background="gray">
{pageIdx === 0 ? (
<>
<SectionHeader
number={sectionNumber}
title={t('trends_timeline')}
subtitle={t('trends_timeline_lead')}
/>
{report.trends_narrative && (
<div style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-brand)', padding: '16px 20px', marginBottom: 20 }}>
<p className="body-sm" style={{ margin: 0, lineHeight: 1.6, color: 'var(--text-secondary)' }}>
{report.trends_narrative}
</p>
</div>
)}
</>
) : (
<div className="caption" style={{ marginBottom: 16, fontSize: 12 }}>
{t('trends_timeline')} ({t('continued')})
</div>
)}
{pageCharts.map((chart) => (
<div key={chart.key}>{chart.render()}</div>
))}
</ReportPage>
))}
</>
);
}

View File

@@ -0,0 +1,618 @@
/* =============================================================================
WhyMyRating — Reputation Blueprint Design System
Premium magazine-style report layout
============================================================================= */
/* ---------- Google Fonts (loaded via next/font in layout.tsx) ---------- */
/* ---------- CSS Variables ---------- */
:root {
/* Brand Logo Colors */
--brand-star: #FBBC05;
--brand-magnifier: #1E293B;
--brand-lens: #FEF3C7;
--brand-bar-light: #86EFAC;
--brand-bar-mid: #22C55E;
--brand-bar-dark: #15803D;
--brand-accent: #F59E0B;
/* UI Colors */
--ui-primary: #4285F4;
--ui-primary-hover: #1E40AF;
--ui-success: #34A853;
--ui-error: #EA4335;
/* Surfaces */
--surface-page: #F8FAFC;
--surface-card: #FFFFFF;
--surface-muted: #F1F5F9;
--surface-dark: #1E293B;
/* Text */
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-tertiary: #94A3B8;
--text-inverse: #FAFAFA;
/* Layout */
--border-light: #E2E8F0;
--radius-brand: 10px;
--font-sans: var(--font-inter), 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-wordmark: var(--font-nunito), 'Nunito', sans-serif;
}
/* ---------- Page System (A4 proportion: 1024×1448px) ---------- */
.page {
width: 1024px;
height: 1448px; /* explicit height — required for child % resolution */
max-height: 1448px;
overflow: hidden;
position: relative;
margin: 0 auto 32px; /* 32px gap between pages for printed-document look */
background: var(--surface-card);
page-break-after: always;
box-sizing: border-box;
box-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
border-radius: 2px;
}
.page-content {
display: flex;
flex-direction: column;
padding: 64px 77px 90px;
height: 100%;
box-sizing: border-box;
position: relative;
}
/* Page background variants */
.page-bg-white { background: var(--surface-card); }
.page-bg-gray { background: var(--surface-page); }
.page-bg-dark {
background: linear-gradient(155deg, #1E293B 0%, #0F172A 100%);
color: var(--text-inverse);
}
/* ---------- Typography ---------- */
.display-xl {
font-family: var(--font-sans);
font-size: 52px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.display-lg {
font-family: var(--font-sans);
font-size: 32px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.display-md {
font-family: var(--font-sans);
font-size: 26px;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
.heading-lg {
font-family: var(--font-sans);
font-size: 20px;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.heading-md {
font-family: var(--font-sans);
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--text-primary);
}
.body-lg {
font-family: var(--font-sans);
font-size: 16px;
font-weight: 400;
line-height: 1.6;
color: var(--text-primary);
}
.body-md {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 400;
line-height: 1.6;
color: var(--text-primary);
}
.body-sm {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 400;
line-height: 1.5;
color: var(--text-secondary);
}
.caption {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 400;
line-height: 1.4;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Text color helpers */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-inverse { color: var(--text-inverse); }
.text-brand { color: var(--ui-primary); }
/* ---------- Page Header & Footer ---------- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
margin-bottom: 28px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.page-header-logo {
display: flex;
align-items: center;
gap: 8px;
}
.page-header-right {
display: flex;
align-items: center;
gap: 16px;
}
.page-indicator {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
letter-spacing: 0.05em;
}
.page-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 20px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
margin-top: auto; /* push to bottom when content is short */
}
.page-footer-text {
font-size: 10px;
color: var(--text-tertiary);
letter-spacing: 0.03em;
}
/* ---------- Section Header ---------- */
.section-header {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 28px;
}
.section-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ui-primary);
color: white;
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
flex-shrink: 0;
}
.section-lead {
max-width: 600px;
}
/* ---------- Insight Callout ---------- */
.insight-callout {
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-left: 4px solid var(--ui-primary);
border-radius: 0 var(--radius-brand) var(--radius-brand) 0;
padding: 20px 24px;
margin: 16px 0;
}
.insight-callout.green {
background: linear-gradient(135deg, #F0FDF4 0%, #DCFCE7 100%);
border-left-color: var(--ui-success);
}
/* ---------- Alert Block ---------- */
.alert-block {
background: linear-gradient(135deg, #FEF2F2 0%, #FECACA40 100%);
border-left: 4px solid var(--ui-error);
border-radius: 0 var(--radius-brand) var(--radius-brand) 0;
padding: 16px 20px;
margin: 16px 0;
}
.alert-block .alert-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ui-error);
margin-bottom: 6px;
}
/* ---------- Stats Grid ---------- */
.stats-row {
display: flex;
gap: 16px;
margin: 16px 0;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 16px 12px;
background: var(--surface-muted);
border-radius: var(--radius-brand);
}
.stat-item.dark {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
}
.stat-value {
font-size: 32px;
font-weight: 700;
line-height: 1.1;
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-top: 4px;
}
.stat-item.dark .stat-label {
color: rgba(255,255,255,0.6);
}
/* ---------- Score Bar ---------- */
.score-bar-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.score-bar-row {
display: flex;
align-items: center;
gap: 12px;
}
.score-bar-label {
width: 130px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.score-bar-track {
flex: 1;
height: 10px;
background: var(--surface-muted);
border-radius: 5px;
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: 5px;
transition: width 0.5s ease;
}
.score-bar-value {
width: 50px;
font-size: 14px;
font-weight: 700;
text-align: right;
flex-shrink: 0;
}
/* ---------- Scoring Table (dark style) ---------- */
.scoring-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: var(--radius-brand);
overflow: hidden;
}
.scoring-table thead th {
background: var(--surface-dark);
color: var(--text-inverse);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 16px;
text-align: left;
}
.scoring-table tbody td {
padding: 12px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-light);
}
.scoring-table tbody tr:nth-child(even) {
background: var(--surface-muted);
}
.scoring-table tbody tr:last-child td {
border-bottom: none;
}
.score-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.score-badge.green {
background: #DCFCE7;
color: #15803D;
}
.score-badge.amber {
background: #FEF3C7;
color: #92400E;
}
/* ---------- Complaint / Issue List ---------- */
.issue-item {
display: flex;
gap: 16px;
padding: 20px;
border: 1px solid var(--border-light);
border-radius: var(--radius-brand);
margin-bottom: 14px;
}
.issue-rank {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #FEE2E2;
color: #DC2626;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.issue-rank.green {
background: #DCFCE7;
color: #16A34A;
}
.issue-body {
flex: 1;
min-width: 0;
}
.issue-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.issue-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.complexity-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.complexity-badge.quick { background: #DCFCE7; color: #15803D; }
.complexity-badge.medium { background: #DBEAFE; color: #1D4ED8; }
.complexity-badge.complex { background: #FEF3C7; color: #92400E; }
/* ---------- Blockquote ---------- */
.evidence-quote {
border-left: 3px solid var(--border-light);
padding: 8px 16px;
margin: 8px 0;
font-style: italic;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.evidence-quote.red { border-left-color: #FCA5A5; }
.evidence-quote.green { border-left-color: #86EFAC; }
.evidence-quote.blue { border-left-color: #93C5FD; }
/* ---------- Framework / Strength Items ---------- */
.framework-item {
display: flex;
gap: 16px;
padding: 20px;
background: var(--surface-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-brand);
margin-bottom: 14px;
}
.framework-number {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ui-primary);
color: white;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.framework-number.green {
background: var(--ui-success);
}
.framework-body {
flex: 1;
min-width: 0;
}
/* ---------- Info Block ---------- */
.info-block {
background: var(--surface-muted);
border-radius: 8px;
padding: 14px 16px;
margin-top: 10px;
}
.info-block .info-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
margin-bottom: 4px;
}
.info-block.green {
background: #F0FDF4;
}
.info-block.blue {
background: #EFF6FF;
}
/* ---------- CTA Block ---------- */
.cta-block {
background: linear-gradient(135deg, var(--ui-primary) 0%, #1E40AF 100%);
border-radius: var(--radius-brand);
padding: 32px;
text-align: center;
color: white;
margin-top: 24px;
}
.cta-block h3 {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
}
.cta-block p {
font-size: 14px;
opacity: 0.85;
margin-bottom: 16px;
}
/* ---------- Pill / Tag ---------- */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.pill.dark {
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.85);
border: 1px solid rgba(255,255,255,0.15);
}
.pill.light {
background: var(--surface-muted);
color: var(--text-secondary);
}
/* ---------- Cover-specific ---------- */
.cover-score-gauge {
margin: 32px 0 24px;
}
.cover-feature-pills {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 24px;
}
/* ---------- Effort / Impact Badges ---------- */
.effort-badge, .impact-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.effort-badge.low { background: #DCFCE7; color: #15803D; }
.effort-badge.medium { background: #FEF3C7; color: #92400E; }
.effort-badge.high { background: #FEE2E2; color: #DC2626; }
.impact-badge.high { background: #DCFCE7; color: #15803D; }
.impact-badge.medium { background: #FEF3C7; color: #92400E; }
.impact-badge.low { background: #F3F4F6; color: #6B7280; }
/* ---------- Domain dot ---------- */
.domain-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

View File

@@ -0,0 +1,177 @@
/**
* Reputation Blueprint report theme.
*
* Infographic-style color palette, typography scale, and helper
* functions for the premium report product.
*/
// =============================================================================
// Score Bands — health_score (0-100)
// =============================================================================
export type ScoreBand = 'excellent' | 'good' | 'fair' | 'poor' | 'critical';
const SCORE_BANDS: { min: number; band: ScoreBand; color: string; label: string }[] = [
{ min: 90, band: 'excellent', color: '#059669', label: 'Excellent' },
{ min: 75, band: 'good', color: '#22c55e', label: 'Good' },
{ min: 60, band: 'fair', color: '#f59e0b', label: 'Fair' },
{ min: 40, band: 'poor', color: '#f97316', label: 'Poor' },
{ min: 0, band: 'critical', color: '#ef4444', label: 'Critical' },
];
export function getScoreBand(score: number): ScoreBand {
for (const { min, band } of SCORE_BANDS) {
if (score >= min) return band;
}
return 'critical';
}
export function getScoreColor(score: number): string {
for (const { min, color } of SCORE_BANDS) {
if (score >= min) return color;
}
return '#ef4444';
}
const SCORE_LABELS: Record<string, Record<string, string>> = {
en: { excellent: 'Excellent', good: 'Good', fair: 'Fair', poor: 'Poor', critical: 'Critical' },
es: { excellent: 'Excelente', good: 'Bueno', fair: 'Regular', poor: 'Malo', critical: 'Crítico' },
};
export function getScoreLabel(score: number, locale: string = 'en'): string {
const band = getScoreBand(score);
return SCORE_LABELS[locale]?.[band] ?? SCORE_LABELS.en?.[band] ?? band;
}
// =============================================================================
// Domain Colors — URT domains (O, P, J, E, V, M)
// =============================================================================
const DOMAIN_COLORS: Record<string, string> = {
O: '#3b82f6', // blue — Output Quality
P: '#22c55e', // green — People & Service
J: '#f59e0b', // amber — Journey & Process
E: '#8b5cf6', // purple — Environment
V: '#f43f5e', // rose — Value
M: '#6b7280', // gray — Meta / Other
};
export function getDomainColor(domain: string): string {
return DOMAIN_COLORS[domain.charAt(0).toUpperCase()] || DOMAIN_COLORS.M || '#6b7280';
}
// =============================================================================
// Risk Colors — indicator color field
// =============================================================================
const RISK_COLORS: Record<string, string> = {
green: '#059669',
yellow: '#f59e0b',
red: '#ef4444',
};
export function getRiskColor(color: string): string {
return RISK_COLORS[color] || RISK_COLORS.yellow || '#f59e0b';
}
// =============================================================================
// Valence Colors
// =============================================================================
const VALENCE_COLORS: Record<string, string> = {
positive: '#22c55e',
negative: '#ef4444',
neutral: '#9ca3af',
mixed: '#f59e0b',
};
export function getValenceColor(valence: string): string {
const key = valence.toLowerCase().replace('v+', 'positive').replace('v-', 'negative').replace('v0', 'neutral').replace('v±', 'mixed');
return VALENCE_COLORS[key] || VALENCE_COLORS.neutral || '#9ca3af';
}
// =============================================================================
// Effort / Impact Colors (action matrix)
// =============================================================================
const EFFORT_COLORS: Record<string, string> = {
low: '#22c55e',
medium: '#f59e0b',
high: '#ef4444',
};
export function getEffortColor(effort: string): string {
return EFFORT_COLORS[effort] || EFFORT_COLORS.medium || '#f59e0b';
}
const QUADRANT_COLORS: Record<string, { bg: string; text: string; border: string }> = {
quick_win: { bg: '#ecfdf5', text: '#059669', border: '#a7f3d0' },
major_project: { bg: '#eff6ff', text: '#2563eb', border: '#bfdbfe' },
fill_in: { bg: '#fefce8', text: '#ca8a04', border: '#fef08a' },
deprioritize: { bg: '#f3f4f6', text: '#6b7280', border: '#d1d5db' },
};
export function getQuadrantStyle(quadrant: string) {
return QUADRANT_COLORS[quadrant] || QUADRANT_COLORS.major_project;
}
// =============================================================================
// Typography Scale
// =============================================================================
export const typography = {
displayXl: { size: '52px', weight: 700, lineHeight: 1.1 },
displayLg: { size: '32px', weight: 700, lineHeight: 1.15 },
displayMd: { size: '26px', weight: 600, lineHeight: 1.2 },
headingLg: { size: '20px', weight: 600, lineHeight: 1.3 },
headingMd: { size: '17px', weight: 600, lineHeight: 1.35 },
bodyLg: { size: '16px', weight: 400, lineHeight: 1.6 },
bodyMd: { size: '14px', weight: 400, lineHeight: 1.6 },
bodySm: { size: '13px', weight: 400, lineHeight: 1.5 },
caption: { size: '11px', weight: 400, lineHeight: 1.4 },
} as const;
// =============================================================================
// Section Accent Colors (left border + header tint)
// =============================================================================
export const sectionColors = {
cover: '#1E293B',
executive: '#4285F4',
ratingDashboard: '#F59E0B',
themeAnalysis: '#6366F1',
domainPerformance: '#8B5CF6',
criticalIssues: '#EA4335',
strengths: '#34A853',
actionPlan: '#4285F4',
tracking: '#0EA5E9',
} as const;
// =============================================================================
// Momentum Indicator
// =============================================================================
const MOMENTUM_STYLES: Record<string, { color: string; label: string }> = {
improving: { color: '#22c55e', label: 'Improving' },
declining: { color: '#ef4444', label: 'Declining' },
stable: { color: '#6b7280', label: 'Stable' },
};
export function getMomentumStyle(momentum: string) {
return MOMENTUM_STYLES[momentum] || MOMENTUM_STYLES.stable;
}
// =============================================================================
// Chart Palette (for generic series)
// =============================================================================
export const chartPalette = [
'#3b82f6', // blue
'#22c55e', // green
'#f59e0b', // amber
'#8b5cf6', // purple
'#f43f5e', // rose
'#0ea5e9', // sky
'#f97316', // orange
'#6b7280', // gray
];

View File

@@ -0,0 +1,373 @@
/**
* TypeScript types for the Reputation Blueprint report.
*
* These types mirror the backend ReportSynthesis JSON shape
* stored in pipeline.executions.synthesis (stage5_synthesize_v2.py).
*
* ALL labels, vocabulary, and category-specific text come from the
* synthesis JSON. The frontend has zero category configs.
*/
// =============================================================================
// Score Breakdown
// =============================================================================
export interface ScoreBreakdown {
rating_quality: number;
sentiment_depth: number;
volume: number;
momentum: number;
intensity: number;
}
// =============================================================================
// Theme Analysis
// =============================================================================
export interface ThemeScore {
primitive: string;
label: string;
domain: string;
count: number;
weight: number;
valence: {
positive: number;
negative: number;
neutral: number;
mixed: number;
};
intensity: {
i1: number;
i2: number;
i3: number;
};
top_quotes: {
positive: string[];
negative: string[];
};
score_cost?: number; // reputational cost 0-100
}
// =============================================================================
// Domain Performance
// =============================================================================
export interface DomainScore {
domain: string;
label: string;
score: number;
weight: number;
volume: number;
primitives: string[];
narrative?: string;
}
// =============================================================================
// Critical Issues
// =============================================================================
export interface CriticalIssue {
title: string;
primitive: string;
domain: string;
count: number;
intensity_score: number;
description: string;
quotes: string[];
solution: string;
complexity: 'quick' | 'medium' | 'complex';
score_cost?: number; // reputational cost 0-100
}
// =============================================================================
// Strengths
// =============================================================================
export interface Strength {
title: string;
primitive: string;
domain: string;
count: number;
intensity_score: number;
description: string;
quotes: string[];
marketing_angle: string;
}
// =============================================================================
// Action Plan
// =============================================================================
export interface ActionItem {
action: string;
source: string;
owner: string;
effort: 'low' | 'medium' | 'high';
timeline: string;
impact: 'high' | 'medium' | 'low';
success_metric: string;
detail?: string;
evidence?: string;
}
// =============================================================================
// Tracking KPIs
// =============================================================================
export interface KPI {
metric: string;
current: string;
target_30d: string;
target_90d: string;
}
// =============================================================================
// Evidence
// =============================================================================
export interface Evidence {
quote: string;
primitive: string;
valence: string;
context: string;
}
// =============================================================================
// Review Evidence (v3 — full review text with classification anchors)
// =============================================================================
export interface ReviewClassification {
primitive: string;
valence: string; // '+', '-', '0', '±'
anchor_text: string;
anchor_start: number | null;
anchor_end: number | null;
}
export interface ReviewEvidence {
review_id: string;
author: string;
rating: number | null;
date: string | null;
full_text: string;
classifications: ReviewClassification[];
}
// =============================================================================
// Chart Data (pre-computed by backend)
// =============================================================================
export interface ChartDataPoint {
label: string;
value: number;
color?: string;
}
export interface DomainRadarPoint {
axis: string;
value: number;
}
export interface ThemeMatrixPoint {
primitive: string;
label: string;
positive: number;
negative: number;
neutral: number;
mixed: number;
total: number;
}
export interface IntensityHeatmapPoint {
primitive: string;
label: string;
i1: number;
i2: number;
i3: number;
}
export interface RatingDistPoint {
rating: number;
count: number;
}
export interface RatingTrendPoint {
period: string;
avg_rating: number;
review_count: number;
}
export interface MomentumDualPoint {
period: string;
positive: number;
negative: number;
}
export interface QuarterlyRatingPoint {
quarter: string; // "2024-Q1"
avg_rating: number;
review_count: number;
}
export interface QuarterlyDomainSentimentPoint {
quarter: string;
O?: number; P?: number; J?: number; E?: number; V?: number; // 0-100 positive %
}
export interface SeasonalPatternPoint {
quarter_label: string; // "Q1"-"Q4"
avg_rating: number;
review_count: number;
}
export interface ReportCharts {
sentiment_donut: ChartDataPoint[];
domain_radar: DomainRadarPoint[];
theme_matrix: ThemeMatrixPoint[];
intensity_heatmap: IntensityHeatmapPoint[];
rating_distribution: RatingDistPoint[];
rating_trend: RatingTrendPoint[];
momentum_dual: MomentumDualPoint[];
quarterly_rating?: QuarterlyRatingPoint[];
quarterly_domain_sentiment?: QuarterlyDomainSentimentPoint[];
seasonal_pattern?: SeasonalPatternPoint[];
}
// =============================================================================
// Staff Leaderboard
// =============================================================================
export interface StaffMember {
name: string;
total_mentions: number;
positive: number;
negative: number;
sentiment_score: number; // 0-100
positive_quotes?: string[];
negative_quotes?: string[];
}
export interface StaffIndividual {
canonical_name: string;
aliases: string[];
role_inferred: string | null;
positive: number;
negative: number;
total_mentions: number;
sentiment_score: number;
positive_quotes: string[];
negative_quotes: string[];
note: string | null;
}
export interface StaffGroup {
canonical_name: string;
aliases: string[];
positive: number;
negative: number;
total_mentions: number;
sentiment_score: number;
positive_quotes: string[];
negative_quotes: string[];
note: string | null;
}
export interface StaffExcluded {
name: string;
reason: string;
}
export interface StaffLeaderboardResolved {
individuals: StaffIndividual[];
groups: StaffGroup[];
excluded?: StaffExcluded[];
observations: string;
}
// =============================================================================
// Top-Level Report Synthesis (matches JSON stored in executions.synthesis)
// =============================================================================
export interface ReportSynthesis {
// Meta
report_version: string;
business_name: string;
category_label: string;
sector_code: string;
report_date: string;
language?: string;
review_count: number;
// Scores
reputation_score: number; // 0-100
score_breakdown: ScoreBreakdown;
current_rating: number;
potential_rating: number;
// Rating Distribution
rating_distribution: Record<string, number>;
// Executive Summary (LLM-generated with sector vocabulary)
headline: string;
verdict: string;
key_findings: string[];
revenue_impact: string;
// AI Narratives (optional — v2.1.0+)
rating_narrative?: string;
themes_narrative?: string;
matrix_narrative?: string;
trends_narrative?: string;
domain_overview?: string;
rating_evolution_narrative?: string;
domain_sentiment_narrative?: string;
seasonal_narrative?: string;
// Themes
themes: ThemeScore[];
// Domains
domains: DomainScore[];
// Critical Issues (LLM-generated solutions)
critical_issues: CriticalIssue[];
// Strengths (LLM-generated marketing angles)
strengths: Strength[];
// Action Plan (LLM-generated)
actions: ActionItem[];
// Tracking
kpis: KPI[];
// Evidence
evidence: Evidence[];
// Staff Leaderboard (array = legacy v2.0.0, object = resolved v2.1.0+)
staff_leaderboard?: StaffMember[] | StaffLeaderboardResolved;
// Review Evidence (v3 — full review text with classification anchors)
review_evidence?: ReviewEvidence[];
// Methodology (v2.2.0+)
methodology?: {
data_source: string;
oldest_review: string | null;
newest_review: string | null;
review_count: number;
classification_model: string;
score_weights: Record<string, number>;
};
// Conclusion (v2.2.0+)
conclusion?: {
takeaways: string[];
ninety_day_focus: string;
review_cadence: string;
cost_of_inaction: string;
};
// Pre-computed chart data
charts: ReportCharts;
}

View File

@@ -0,0 +1,37 @@
import type { ReportSynthesis } from '../types';
/** Count how many trend charts a report has (quarterly rating, domain sentiment, seasonal). */
export function countTrendsCharts(report: ReportSynthesis): number {
let n = 0;
if ((report.charts?.quarterly_rating?.length ?? 0) > 1) n++;
if ((report.charts?.quarterly_domain_sentiment?.length ?? 0) > 1) n++;
if ((report.charts?.seasonal_pattern?.length ?? 0) > 1) n++;
return n;
}
/** Count pages needed for the trends section, accounting for AI narrative. */
export function countTrendsPages(report: ReportSynthesis): number {
const chartCount = countTrendsCharts(report);
if (chartCount === 0) return 0;
const hasNarrative = !!report.trends_narrative;
// With narrative: 1 chart on first page, 2 on continuation
// Without narrative: 2 charts on first page, 3 on continuation
return countPages(chartCount, hasNarrative ? 1 : 2, hasNarrative ? 2 : 3);
}
export function paginateItems<T>(items: T[], firstPageLimit: number, nextPageLimit: number): T[][] {
if (items.length <= firstPageLimit) return [items];
const pages: T[][] = [items.slice(0, firstPageLimit)];
let offset = firstPageLimit;
while (offset < items.length) {
pages.push(items.slice(offset, offset + nextPageLimit));
offset += nextPageLimit;
}
return pages;
}
export function countPages(itemCount: number, firstPageLimit: number, nextPageLimit: number): number {
if (itemCount === 0) return 0;
if (itemCount <= firstPageLimit) return 1;
return 1 + Math.ceil((itemCount - firstPageLimit) / nextPageLimit);
}

View File

@@ -15,7 +15,7 @@ const links = [
}, },
{ {
label: "marketing:demoLabel", label: "marketing:demoLabel",
href: "/#report-preview", href: pathsConfig.demo.report,
}, },
{ {
label: "billing:pricing.label", label: "billing:pricing.label",

View File

@@ -1,5 +1,9 @@
{ {
"demoLabel": "Demo", "demoLabel": "Demo",
"demoPage": {
"ctaText": "Like what you see? Get yours",
"ctaButton": "Get your Blueprint"
},
"product": { "product": {
"title": "See exactly what's driving your Google rating", "title": "See exactly what's driving your Google rating",
"description": "WhyRating analyzes every review and shows you what customers love, what frustrates them, and what to fix first. Enterprise-grade insights. Small business price. No subscription." "description": "WhyRating analyzes every review and shows you what customers love, what frustrates them, and what to fix first. Enterprise-grade insights. Small business price. No subscription."

View File

@@ -1,5 +1,9 @@
{ {
"demoLabel": "Demo", "demoLabel": "Demo",
"demoPage": {
"ctaText": "¿Te gusta lo que ves? Obtén el tuyo",
"ctaButton": "Obtén tu Radiografía"
},
"product": { "product": {
"title": "Descubre qué está impulsando tu calificación en Google", "title": "Descubre qué está impulsando tu calificación en Google",
"description": "WhyRating analiza cada reseña y te muestra qué les encanta a tus clientes, qué les frustra y qué deberías corregir primero. Análisis de nivel empresarial. Precio para pequeños negocios. Sin suscripción." "description": "WhyRating analiza cada reseña y te muestra qué les encanta a tus clientes, qué les frustra y qué deberías corregir primero. Análisis de nivel empresarial. Precio para pequeños negocios. Sin suscripción."