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:
35
apps/web/src/app/[locale]/(marketing)/report-demo/page.tsx
Normal file
35
apps/web/src/app/[locale]/(marketing)/report-demo/page.tsx
Normal 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")} →
|
||||||
|
</TurboLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
1643
apps/web/src/modules/marketing/demo/demo-synthesis.ts
Normal file
1643
apps/web/src/modules/marketing/demo/demo-synthesis.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}% · {entry.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
418
apps/web/src/modules/marketing/demo/report/i18n/translations.ts
Normal file
418
apps/web/src/modules/marketing/demo/report/i18n/translations.ts
Normal 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 (0–100)',
|
||||||
|
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 (0–100)',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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> · {t('source')}: {translatePrimitive(action.source, locale)}
|
||||||
|
{mentionCount != null && (
|
||||||
|
<span style={{ color: 'var(--ui-primary, #4285F4)', fontWeight: 600 }}> ({mentionCount} {t('mentions')})</span>
|
||||||
|
)}
|
||||||
|
{' '}· {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 }}>
|
||||||
|
“{action.evidence}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
“{quote}”
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')} · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/modules/marketing/demo/report/sections/EndPage.tsx
Normal file
126
apps/web/src/modules/marketing/demo/report/sections/EndPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} · {translateTimeline(action.timeline, locale)} · <strong>{translateLevel(action.impact, locale)} {t('impact')}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReportPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: '0–39', color: '#ef4444', key: 'score_critical', width: 40 },
|
||||||
|
{ range: '40–59', color: '#f97316', key: 'score_poor', width: 20 },
|
||||||
|
{ range: '60–74', color: '#f59e0b', key: 'score_fair', width: 15 },
|
||||||
|
{ range: '75–89', color: '#22c55e', key: 'score_good', width: 15 },
|
||||||
|
{ range: '90–100', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
“{topPerformer.positive_quotes[0]}”
|
||||||
|
</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' }}>⚠</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 }}>
|
||||||
|
“{topPerformer.positive_quotes[0]}”
|
||||||
|
</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' }}>⚠</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">
|
||||||
|
“{q}”
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
“{quote}”
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }}>
|
||||||
|
“{theme.top_quotes.negative[0]}”
|
||||||
|
</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' }}>
|
||||||
|
“{theme.top_quotes.positive[0]}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ReportPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
373
apps/web/src/modules/marketing/demo/report/types.ts
Normal file
373
apps/web/src/modules/marketing/demo/report/types.ts
Normal 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;
|
||||||
|
}
|
||||||
37
apps/web/src/modules/marketing/demo/report/utils/paginate.ts
Normal file
37
apps/web/src/modules/marketing/demo/report/utils/paginate.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user