diff --git a/apps/web/src/app/[locale]/(marketing)/report-demo/page.tsx b/apps/web/src/app/[locale]/(marketing)/report-demo/page.tsx new file mode 100644 index 0000000..e3fca66 --- /dev/null +++ b/apps/web/src/app/[locale]/(marketing)/report-demo/page.tsx @@ -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 ( +
+ + + {/* Sticky CTA bar */} +
+
+

+ {t("demoPage.ctaText")} +

+ + {t("demoPage.ctaButton")} → + +
+
+
+ ); +} diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index dec5480..597289c 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -19,6 +19,7 @@ const pathsConfig = { index: "/", demo: { index: DEMO_PREFIX, + report: "/report-demo", scrollTest: `${DEMO_PREFIX}/scroll-test`, }, apps: { diff --git a/apps/web/src/modules/marketing/demo/demo-synthesis.ts b/apps/web/src/modules/marketing/demo/demo-synthesis.ts new file mode 100644 index 0000000..f5b5806 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/demo-synthesis.ts @@ -0,0 +1,1643 @@ +/** + * Demo synthesis data for the marketing demo page. + * Anonymized version of a real ReportSynthesis. + */ + +// ============================================================================= +// Types (copied from whymyrating-engine for self-containment) +// ============================================================================= + +/** + * 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 +// ============================================================================= + +interface ScoreBreakdown { + rating_quality: number; + sentiment_depth: number; + volume: number; + momentum: number; + intensity: number; +} + +// ============================================================================= +// Theme Analysis +// ============================================================================= + +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 +// ============================================================================= + +interface DomainScore { + domain: string; + label: string; + score: number; + weight: number; + volume: number; + primitives: string[]; + narrative?: string; +} + +// ============================================================================= +// Critical Issues +// ============================================================================= + +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 +// ============================================================================= + +interface Strength { + title: string; + primitive: string; + domain: string; + count: number; + intensity_score: number; + description: string; + quotes: string[]; + marketing_angle: string; +} + +// ============================================================================= +// Action Plan +// ============================================================================= + +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 +// ============================================================================= + +interface KPI { + metric: string; + current: string; + target_30d: string; + target_90d: string; +} + +// ============================================================================= +// Evidence +// ============================================================================= + +interface Evidence { + quote: string; + primitive: string; + valence: string; + context: string; +} + +// ============================================================================= +// Review Evidence (v3 — full review text with classification anchors) +// ============================================================================= + +interface ReviewClassification { + primitive: string; + valence: string; // '+', '-', '0', '±' + anchor_text: string; + anchor_start: number | null; + anchor_end: number | null; +} + +interface ReviewEvidence { + review_id: string; + author: string; + rating: number | null; + date: string | null; + full_text: string; + classifications: ReviewClassification[]; +} + +// ============================================================================= +// Chart Data (pre-computed by backend) +// ============================================================================= + +interface ChartDataPoint { + label: string; + value: number; + color?: string; +} + +interface DomainRadarPoint { + axis: string; + value: number; +} + +interface ThemeMatrixPoint { + primitive: string; + label: string; + positive: number; + negative: number; + neutral: number; + mixed: number; + total: number; +} + +interface IntensityHeatmapPoint { + primitive: string; + label: string; + i1: number; + i2: number; + i3: number; +} + +interface RatingDistPoint { + rating: number; + count: number; +} + +interface RatingTrendPoint { + period: string; + avg_rating: number; + review_count: number; +} + +interface MomentumDualPoint { + period: string; + positive: number; + negative: number; +} + +interface QuarterlyRatingPoint { + quarter: string; // "2024-Q1" + avg_rating: number; + review_count: number; +} + +interface QuarterlyDomainSentimentPoint { + quarter: string; + O?: number; P?: number; J?: number; E?: number; V?: number; // 0-100 positive % +} + +interface SeasonalPatternPoint { + quarter_label: string; // "Q1"-"Q4" + avg_rating: number; + review_count: number; +} + +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 +// ============================================================================= + +interface StaffMember { + name: string; + total_mentions: number; + positive: number; + negative: number; + sentiment_score: number; // 0-100 + positive_quotes?: string[]; + negative_quotes?: string[]; +} + +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; +} + +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; +} + +interface StaffExcluded { + name: string; + reason: string; +} + +interface StaffLeaderboardResolved { + individuals: StaffIndividual[]; + groups: StaffGroup[]; + excluded?: StaffExcluded[]; + observations: string; +} + +// ============================================================================= +// Top-Level Report Synthesis (matches JSON stored in executions.synthesis) +// ============================================================================= + +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; + + // 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; + }; + + // Conclusion (v2.2.0+) + conclusion?: { + takeaways: string[]; + ninety_day_focus: string; + review_cadence: string; + cost_of_inaction: string; + }; + + // Pre-computed chart data + charts: ReportCharts; +} + + +// ============================================================================= +// Demo Data +// ============================================================================= + +export const demoSynthesis: ReportSynthesis = { + "kpis": [], + "charts": { + "domain_radar": [ + { + "axis": "People/Service", + "value": 98.2 + }, + { + "axis": "Journey/Process", + "value": 97.5 + }, + { + "axis": "Value", + "value": 89.2 + }, + { + "axis": "Output/Product", + "value": 95.2 + }, + { + "axis": "Environment", + "value": 100.0 + } + ], + "rating_trend": [], + "theme_matrix": [ + { + "label": "Manner/Attitude", + "mixed": 0, + "total": 471, + "neutral": 0, + "negative": 9, + "positive": 462, + "primitive": "MANNER" + }, + { + "label": "Speed/Wait", + "mixed": 0, + "total": 129, + "neutral": 0, + "negative": 0, + "positive": 129, + "primitive": "SPEED" + }, + { + "label": "Competence", + "mixed": 0, + "total": 47, + "neutral": 0, + "negative": 0, + "positive": 47, + "primitive": "COMPETENCE" + }, + { + "label": "Recommend", + "mixed": 0, + "total": 45, + "neutral": 0, + "negative": 0, + "positive": 45, + "primitive": "RECOMMEND" + }, + { + "label": "Price Fairness", + "mixed": 0, + "total": 41, + "neutral": 0, + "negative": 3, + "positive": 38, + "primitive": "PRICE_FAIRNESS" + }, + { + "label": "Attentiveness", + "mixed": 0, + "total": 27, + "neutral": 0, + "negative": 1, + "positive": 26, + "primitive": "ATTENTIVENESS" + }, + { + "label": "Value for Money", + "mixed": 0, + "total": 24, + "neutral": 0, + "negative": 5, + "positive": 19, + "primitive": "VALUE_FOR_MONEY" + }, + { + "label": "Effectiveness", + "mixed": 0, + "total": 23, + "neutral": 0, + "negative": 1, + "positive": 22, + "primitive": "EFFECTIVENESS" + }, + { + "label": "Friction", + "mixed": 0, + "total": 16, + "neutral": 0, + "negative": 3, + "positive": 13, + "primitive": "FRICTION" + }, + { + "label": "Reliability", + "mixed": 0, + "total": 15, + "neutral": 0, + "negative": 1, + "positive": 14, + "primitive": "RELIABILITY" + }, + { + "label": "Communication", + "mixed": 0, + "total": 14, + "neutral": 0, + "negative": 0, + "positive": 14, + "primitive": "COMMUNICATION" + }, + { + "label": "Accuracy", + "mixed": 0, + "total": 11, + "neutral": 0, + "negative": 0, + "positive": 11, + "primitive": "ACCURACY" + }, + { + "label": "Price Transparency", + "mixed": 0, + "total": 8, + "neutral": 0, + "negative": 0, + "positive": 8, + "primitive": "PRICE_TRANSPARENCY" + }, + { + "label": "Honesty", + "mixed": 0, + "total": 6, + "neutral": 0, + "negative": 0, + "positive": 6, + "primitive": "HONESTY" + }, + { + "label": "Taste/Flavor", + "mixed": 0, + "total": 5, + "neutral": 0, + "negative": 1, + "positive": 4, + "primitive": "TASTE" + } + ], + "momentum_dual": [], + "sentiment_donut": [ + { + "color": "#22c55e", + "label": "Positive", + "value": 867 + }, + { + "color": "#ef4444", + "label": "Negative", + "value": 24 + }, + { + "color": "#94a3b8", + "label": "Neutral", + "value": 0 + }, + { + "color": "#f59e0b", + "label": "Mixed", + "value": 0 + } + ], + "quarterly_rating": [ + { + "quarter": "2024-Q2", + "avg_rating": 4.92, + "review_count": 37 + }, + { + "quarter": "2024-Q3", + "avg_rating": 4.95, + "review_count": 78 + }, + { + "quarter": "2024-Q4", + "avg_rating": 4.83, + "review_count": 64 + }, + { + "quarter": "2025-Q1", + "avg_rating": 4.92, + "review_count": 106 + }, + { + "quarter": "2025-Q2", + "avg_rating": 4.88, + "review_count": 64 + }, + { + "quarter": "2025-Q3", + "avg_rating": 4.88, + "review_count": 83 + }, + { + "quarter": "2025-Q4", + "avg_rating": 4.94, + "review_count": 112 + }, + { + "quarter": "2026-Q1", + "avg_rating": 4.75, + "review_count": 52 + } + ], + "seasonal_pattern": [ + { + "avg_rating": 4.86, + "review_count": 158, + "quarter_label": "Q1" + }, + { + "avg_rating": 4.89, + "review_count": 101, + "quarter_label": "Q2" + }, + { + "avg_rating": 4.91, + "review_count": 161, + "quarter_label": "Q3" + }, + { + "avg_rating": 4.9, + "review_count": 176, + "quarter_label": "Q4" + } + ], + "intensity_heatmap": [ + { + "i1": 0, + "i2": 209, + "i3": 262, + "label": "Manner/Attitude", + "primitive": "MANNER" + }, + { + "i1": 0, + "i2": 93, + "i3": 36, + "label": "Speed/Wait", + "primitive": "SPEED" + }, + { + "i1": 0, + "i2": 28, + "i3": 19, + "label": "Competence", + "primitive": "COMPETENCE" + }, + { + "i1": 0, + "i2": 13, + "i3": 32, + "label": "Recommend", + "primitive": "RECOMMEND" + }, + { + "i1": 0, + "i2": 31, + "i3": 10, + "label": "Price Fairness", + "primitive": "PRICE_FAIRNESS" + }, + { + "i1": 0, + "i2": 16, + "i3": 11, + "label": "Attentiveness", + "primitive": "ATTENTIVENESS" + }, + { + "i1": 0, + "i2": 17, + "i3": 7, + "label": "Value for Money", + "primitive": "VALUE_FOR_MONEY" + }, + { + "i1": 0, + "i2": 10, + "i3": 13, + "label": "Effectiveness", + "primitive": "EFFECTIVENESS" + }, + { + "i1": 1, + "i2": 7, + "i3": 8, + "label": "Friction", + "primitive": "FRICTION" + }, + { + "i1": 0, + "i2": 4, + "i3": 11, + "label": "Reliability", + "primitive": "RELIABILITY" + }, + { + "i1": 0, + "i2": 8, + "i3": 6, + "label": "Communication", + "primitive": "COMMUNICATION" + }, + { + "i1": 0, + "i2": 10, + "i3": 1, + "label": "Accuracy", + "primitive": "ACCURACY" + }, + { + "i1": 0, + "i2": 3, + "i3": 5, + "label": "Price Transparency", + "primitive": "PRICE_TRANSPARENCY" + }, + { + "i1": 0, + "i2": 4, + "i3": 2, + "label": "Honesty", + "primitive": "HONESTY" + }, + { + "i1": 1, + "i2": 3, + "i3": 1, + "label": "Taste/Flavor", + "primitive": "TASTE" + } + ], + "rating_distribution": [ + { + "count": 10, + "rating": 1 + }, + { + "count": 3, + "rating": 2 + }, + { + "count": 2, + "rating": 3 + }, + { + "count": 12, + "rating": 4 + }, + { + "count": 569, + "rating": 5 + } + ], + "quarterly_domain_sentiment": [ + { + "J": 100.0, + "O": 100.0, + "P": 100.0, + "V": 60.0, + "quarter": "2024-Q2" + }, + { + "J": 92.3, + "O": 100.0, + "P": 100.0, + "V": 100.0, + "quarter": "2024-Q3" + }, + { + "J": 100.0, + "O": 100.0, + "P": 98.3, + "V": 87.5, + "quarter": "2024-Q4" + }, + { + "J": 95.5, + "O": 83.3, + "P": 99.1, + "V": 100.0, + "quarter": "2025-Q1" + }, + { + "J": 92.9, + "O": 100.0, + "P": 98.5, + "V": 80.0, + "quarter": "2025-Q2" + }, + { + "J": 100.0, + "O": 100.0, + "P": 95.5, + "V": 100.0, + "quarter": "2025-Q3" + }, + { + "J": 100.0, + "P": 99.1, + "V": 83.3, + "quarter": "2025-Q4" + }, + { + "J": 100.0, + "O": 100.0, + "P": 92.5, + "V": 83.3, + "quarter": "2026-Q1" + } + ] + }, + "themes": [ + { + "count": 471, + "label": "Manner/Attitude", + "domain": "P", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 9, + "positive": 462 + }, + "intensity": { + "i1": 0, + "i2": 209, + "i3": 262 + }, + "primitive": "MANNER", + "score_cost": 1.2, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 129, + "label": "Speed/Wait", + "domain": "J", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 129 + }, + "intensity": { + "i1": 0, + "i2": 93, + "i3": 36 + }, + "primitive": "SPEED", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 47, + "label": "Competence", + "domain": "P", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 47 + }, + "intensity": { + "i1": 0, + "i2": 28, + "i3": 19 + }, + "primitive": "COMPETENCE", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 45, + "label": "Recommend", + "domain": "meta", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 45 + }, + "intensity": { + "i1": 0, + "i2": 13, + "i3": 32 + }, + "primitive": "RECOMMEND", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 41, + "label": "Price Fairness", + "domain": "V", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 3, + "positive": 38 + }, + "intensity": { + "i1": 0, + "i2": 31, + "i3": 10 + }, + "primitive": "PRICE_FAIRNESS", + "score_cost": 0.3, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 27, + "label": "Attentiveness", + "domain": "P", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 1, + "positive": 26 + }, + "intensity": { + "i1": 0, + "i2": 16, + "i3": 11 + }, + "primitive": "ATTENTIVENESS", + "score_cost": 0.2, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 24, + "label": "Value for Money", + "domain": "V", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 5, + "positive": 19 + }, + "intensity": { + "i1": 0, + "i2": 17, + "i3": 7 + }, + "primitive": "VALUE_FOR_MONEY", + "score_cost": 0.7, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 23, + "label": "Effectiveness", + "domain": "O", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 1, + "positive": 22 + }, + "intensity": { + "i1": 0, + "i2": 10, + "i3": 13 + }, + "primitive": "EFFECTIVENESS", + "score_cost": 0.2, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 16, + "label": "Friction", + "domain": "J", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 3, + "positive": 13 + }, + "intensity": { + "i1": 1, + "i2": 7, + "i3": 8 + }, + "primitive": "FRICTION", + "score_cost": 0.2, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 15, + "label": "Reliability", + "domain": "J", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 1, + "positive": 14 + }, + "intensity": { + "i1": 0, + "i2": 4, + "i3": 11 + }, + "primitive": "RELIABILITY", + "score_cost": 0.2, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 14, + "label": "Communication", + "domain": "P", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 14 + }, + "intensity": { + "i1": 0, + "i2": 8, + "i3": 6 + }, + "primitive": "COMMUNICATION", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 11, + "label": "Accuracy", + "domain": "O", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 11 + }, + "intensity": { + "i1": 0, + "i2": 10, + "i3": 1 + }, + "primitive": "ACCURACY", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 8, + "label": "Price Transparency", + "domain": "V", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 8 + }, + "intensity": { + "i1": 0, + "i2": 3, + "i3": 5 + }, + "primitive": "PRICE_TRANSPARENCY", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 6, + "label": "Honesty", + "domain": "meta", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 6 + }, + "intensity": { + "i1": 0, + "i2": 4, + "i3": 2 + }, + "primitive": "HONESTY", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 5, + "label": "Taste/Flavor", + "domain": "O", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 1, + "positive": 4 + }, + "intensity": { + "i1": 1, + "i2": 3, + "i3": 1 + }, + "primitive": "TASTE", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 3, + "label": "Return Intent", + "domain": "meta", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 3 + }, + "intensity": { + "i1": 0, + "i2": 1, + "i3": 2 + }, + "primitive": "RETURN_INTENT", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 2, + "label": "Craftsmanship", + "domain": "O", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 2 + }, + "intensity": { + "i1": 0, + "i2": 1, + "i3": 1 + }, + "primitive": "CRAFT", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 1, + "label": "Freshness", + "domain": "O", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 1 + }, + "intensity": { + "i1": 0, + "i2": 1, + "i3": 0 + }, + "primitive": "FRESHNESS", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 1, + "label": "Price Level", + "domain": "V", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 1 + }, + "intensity": { + "i1": 0, + "i2": 0, + "i3": 1 + }, + "primitive": "PRICE_LEVEL", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 1, + "label": "Ambiance", + "domain": "E", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 1 + }, + "intensity": { + "i1": 0, + "i2": 1, + "i3": 0 + }, + "primitive": "AMBIANCE", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + }, + { + "count": 1, + "label": "Safety", + "domain": "E", + "weight": 1.0, + "valence": { + "mixed": 0, + "neutral": 0, + "negative": 0, + "positive": 1 + }, + "intensity": { + "i1": 0, + "i2": 1, + "i3": 0 + }, + "primitive": "SAFETY", + "score_cost": 0.0, + "top_quotes": { + "positive": [], + "negative": [] + } + } + ], + "actions": [ + { + "owner": "gerente", + "action": "Abordar la actitud del tasador.", + "detail": "", + "effort": "medium", + "impact": "high", + "source": "MANNER", + "evidence": "", + "timeline": "This month", + "success_metric": "Reducción del 50% en menciones negativas sobre la actitud en los próximos 60 días." + }, + { + "owner": "gerente", + "action": "Mejorar la claridad en la tasación online.", + "detail": "", + "effort": "medium", + "impact": "medium", + "source": "FRICTION", + "evidence": "", + "timeline": "This month", + "success_metric": "Lograr que al menos el 80% de los clientes mencionen un proceso más claro en sus reseñas en los próximos 90 días." + }, + { + "owner": "gerente", + "action": "Revisar las estimaciones de tasación.", + "detail": "", + "effort": "high", + "impact": "high", + "source": "VALUE_FOR_MONEY", + "evidence": "", + "timeline": "This quarter", + "success_metric": "Incrementar en un 30% las menciones positivas sobre la tasación en los siguientes 90 días." + }, + { + "owner": "gerente", + "action": "Fortalecer la comunicación post-tasación.", + "detail": "", + "effort": "medium", + "impact": "high", + "source": "RELIABILITY", + "evidence": "", + "timeline": "This month", + "success_metric": "Alcanzar un 95% de satisfacción en la comunicación de pagos en las reseñas en los próximos 60 días." + }, + { + "owner": "gerente", + "action": "Reforzar la capacitación en atención al cliente.", + "detail": "", + "effort": "medium", + "impact": "medium", + "source": "ATTENTIVENESS", + "evidence": "", + "timeline": "This month", + "success_metric": "Lograr un 90% de menciones positivas sobre la atención al cliente en las próximas 90 días." + } + ], + "domains": [ + { + "label": "People/Service", + "score": 98.2, + "domain": "P", + "volume": 559, + "weight": 66.8, + "narrative": "", + "primitives": [ + "MANNER", + "COMPETENCE", + "ATTENTIVENESS", + "COMMUNICATION" + ] + }, + { + "label": "Journey/Process", + "score": 97.5, + "domain": "J", + "volume": 160, + "weight": 19.1, + "narrative": "", + "primitives": [ + "SPEED", + "FRICTION", + "RELIABILITY" + ] + }, + { + "label": "Value", + "score": 89.2, + "domain": "V", + "volume": 74, + "weight": 8.8, + "narrative": "", + "primitives": [ + "PRICE_FAIRNESS", + "VALUE_FOR_MONEY", + "PRICE_TRANSPARENCY", + "PRICE_LEVEL" + ] + }, + { + "label": "Output/Product", + "score": 95.2, + "domain": "O", + "volume": 42, + "weight": 5.0, + "narrative": "", + "primitives": [ + "EFFECTIVENESS", + "ACCURACY", + "TASTE", + "CRAFT", + "FRESHNESS" + ] + }, + { + "label": "Environment", + "score": 100.0, + "domain": "E", + "volume": 2, + "weight": 0.2, + "narrative": "", + "primitives": [ + "AMBIANCE", + "SAFETY" + ] + } + ], + "verdict": "Con una puntuación de reputación de 83/100 y un promedio de 4.89 estrellas, su negocio se posiciona bien en el mercado. Sin embargo, comentarios negativos sobre la actitud del personal y la valoración de vehículos indican áreas críticas que requieren atención.", + "evidence": [], + "headline": "Reputación sólida con áreas de mejora en trato y valoración.", + "language": "es", + "strengths": [ + { + "count": 462, + "title": "Manner/Attitude", + "domain": "P", + "quotes": [], + "primitive": "MANNER", + "description": "462 clientes destacan la atención impecable y la amabilidad del personal. Comentarios como 'Excelente trato, muy buena atención' reflejan una experiencia positiva.", + "intensity_score": 1438, + "marketing_angle": "Publicar testimonios de clientes satisfechos en la página web y en redes sociales para resaltar la calidad del servicio al cliente." + }, + { + "count": 129, + "title": "Speed/Wait", + "domain": "J", + "quotes": [], + "primitive": "SPEED", + "description": "129 reseñas elogian la rapidez del proceso de revisión y venta. Frases como 'súper rápido todo el proceso' indican eficiencia en el servicio.", + "intensity_score": 330, + "marketing_angle": "Crear un video corto que muestre el proceso rápido de tasación y venta, compartiéndolo en plataformas como Instagram y TikTok." + }, + { + "count": 45, + "title": "Recommend", + "domain": "meta", + "quotes": [], + "primitive": "RECOMMEND", + "description": "45 clientes han declarado que recomiendan el servicio, con comentarios como 'Muy recomendable' y 'Totalmente recomendables'.", + "intensity_score": 154, + "marketing_angle": "Iniciar un programa de referidos donde los clientes actuales puedan obtener beneficios al recomendar a nuevos clientes." + }, + { + "count": 47, + "title": "Competence", + "domain": "P", + "quotes": [], + "primitive": "COMPETENCE", + "description": "47 reseñas destacan la competencia del equipo, mencionando que ayudan con todos los trámites y dudas, y alabando el servicio de Padilla, Alex y Silvia.", + "intensity_score": 132, + "marketing_angle": "Destacar la experiencia y profesionalismo del equipo en publicaciones de blog y en la sección 'Conócenos' del sitio web." + }, + { + "count": 38, + "title": "Price Fairness", + "domain": "V", + "quotes": [], + "primitive": "PRICE_FAIRNESS", + "description": "38 clientes han expresado satisfacción con la tasación inicial y el precio pactado. Comentarios como 'me respetaron la tasación inicial' son comunes.", + "intensity_score": 95, + "marketing_angle": "Desarrollar una campaña de marketing que enfatice la transparencia en las tasaciones y precios, utilizando infografías en redes sociales." + } + ], + "conclusion": { + "takeaways": [ + "El 98% de los comentarios sobre el trato y la actitud son positivos, pero 9 clientes mencionaron un trato nefasta de algunos tasadores.", + "El 89% de los clientes consideran que la relación calidad-precio es buena, aunque 5 mencionaron que la tasación fue excesivamente baja.", + "La velocidad del proceso es un punto fuerte, con 129 menciones positivas, pero 3 clientes reportaron fricción en la comunicación del proceso de tasación." + ], + "review_cadence": "Recomiendo realizar este análisis cada tres meses para monitorear el progreso y ajustar estrategias según sea necesario.", + "cost_of_inaction": "Si no aborda las quejas sobre el trato y la tasación, podría perder entre el 5% y el 10% de clientes potenciales, lo que impactaría negativamente en las ventas y reputación. Además, la percepción de falta de profesionalidad podría dañar la confianza de los clientes existentes.", + "ninety_day_focus": "Enfóquese en mejorar la experiencia de los clientes durante la tasación. Aborde las quejas sobre el trato y la actitud de algunos tasadores para evitar que se repitan. Considere la posibilidad de capacitar a su equipo en habilidades de atención al cliente y claridad en la comunicación de precios y procesos." + }, + "methodology": { + "data_source": "Google Maps Reviews", + "review_count": 596, + "newest_review": null, + "oldest_review": null, + "score_weights": { + "volume": 0.15, + "momentum": 0.15, + "intensity": 0.15, + "rating_quality": 0.3, + "sentiment_depth": 0.25 + }, + "classification_model": "gpt-4o" + }, + "report_date": "2026-02-18T03:27:52.636987", + "sector_code": "GENERAL", + "key_findings": [ + "El 98% de las menciones sobre el trato del personal son positivas, pero hay 9 comentarios negativos que destacan la falta de profesionalidad.", + "El 89% de los clientes valoran positivamente el precio, aunque 5 clientes mencionan que la tasación fue excesivamente baja.", + "El tiempo de respuesta propietario es alto, con un 97.4%, pero una mejora en la atención al cliente podría reducir las quejas." + ], + "review_count": 596, + "business_name": "Bistro El Sol", + "category_label": "Restaurant", + "current_rating": 4.89, + "report_version": "2.2.0", + "revenue_impact": "Abordar las quejas sobre el trato y la valoración podría mejorar la satisfacción del cliente, lo que a su vez puede aumentar las recomendaciones y las ventas. Una experiencia más positiva podría traducirse en un incremento significativo de ingresos.", + "critical_issues": [ + { + "count": 9, + "title": "Manner/Attitude", + "domain": "P", + "quotes": [], + "solution": "Realice una revisión de los procedimientos de atención al cliente y considere implementar sesiones de retroalimentación para el personal. Priorice la capacitación en habilidades interpersonales para asegurar que todos los empleados mantengan una actitud positiva y profesional en sus interacciones con los clientes.", + "primitive": "MANNER", + "complexity": "medium", + "score_cost": 1.2, + "description": "Nueve clientes mencionaron un trato deficiente, describiendo la actitud del personal como 'nefasta' y 'poco profesional'. Comentarios como 'el trato casi de que te están haciendo un favor' reflejan una percepción negativa sobre la atención al cliente.", + "intensity_score": 28 + }, + { + "count": 5, + "title": "Value for Money", + "domain": "V", + "quotes": [], + "solution": "Revise y ajuste sus criterios de tasación para alinearlos con el mercado actual. Considere ofrecer explicaciones más claras sobre cómo se determinan los valores y brindar ejemplos de tasaciones previas para mejorar la transparencia y la confianza del cliente.", + "primitive": "VALUE_FOR_MONEY", + "complexity": "medium", + "score_cost": 0.7, + "description": "Cinco clientes expresaron insatisfacción con la valoración de sus vehículos, indicando que las tasaciones son excesivamente bajas. Comentarios como 'infravaloración del vehículo' y 'bajón de 800€' demuestran una percepción de explotación de la necesidad de los clientes.", + "intensity_score": 13 + }, + { + "count": 3, + "title": "Friction", + "domain": "J", + "quotes": [], + "solution": "Mejore la comunicación en su sitio web sobre la documentación necesaria y considere implementar un mapa o instrucciones más claras sobre cómo llegar a las sucursales. Además, establezca un protocolo para minimizar las demoras y garantizar un proceso más ágil.", + "primitive": "FRICTION", + "complexity": "quick", + "score_cost": 0.2, + "description": "Tres clientes mencionaron fricciones en el proceso, destacando la falta de claridad en la documentación requerida y demoras, como '7 minutos de demora por no encontrar la ubicación'. Esto sugiere que la experiencia del cliente no es tan fluida como debería.", + "intensity_score": 9 + }, + { + "count": 3, + "title": "Price Fairness", + "domain": "V", + "quotes": [], + "solution": "Asegúrese de que todas las estimaciones sean claras y consistentes desde el principio. Considere establecer un sistema de verificación para que las estimaciones iniciales se respeten, y comunique a los clientes cómo se derivan los cambios en las ofertas.", + "primitive": "PRICE_FAIRNESS", + "complexity": "medium", + "score_cost": 0.3, + "description": "Tres clientes se sintieron engañados por la discrepancia entre las estimaciones iniciales y los precios finales, con comentarios como 'la central tira hacia la baja el presupuesto'. Esto genera desconfianza en el proceso de tasación.", + "intensity_score": 7 + }, + { + "count": 1, + "title": "Attentiveness", + "domain": "P", + "quotes": [], + "solution": "Refuerce la importancia de la atención al cliente en su cultura organizacional. Realice sesiones de capacitación centradas en la escucha activa y la asesoría efectiva, asegurando que el personal esté preparado para responder a las preguntas de los clientes.", + "primitive": "ATTENTIVENESS", + "complexity": "medium", + "score_cost": 0.2, + "description": "Un cliente mencionó la falta de interés del personal en explicar o asesorar durante el proceso. Esto puede indicar una desconexión entre las expectativas del cliente y la atención proporcionada.", + "intensity_score": 3 + }, + { + "count": 1, + "title": "Effectiveness", + "domain": "O", + "quotes": [], + "solution": "Realice un análisis de las diferencias en el servicio entre la tasación online y en la sucursal. Asegúrese de que los procesos estén estandarizados y que el personal reciba la misma capacitación y recursos para manejar las evaluaciones de manera eficaz.", + "primitive": "EFFECTIVENESS", + "complexity": "complex", + "score_cost": 0.2, + "description": "Un cliente reportó una experiencia mixta con la tasación online, calificando la atención en la sucursal de Leganés como 'catastrófica'. Esto sugiere una inconsistencia en la calidad del servicio.", + "intensity_score": 3 + }, + { + "count": 1, + "title": "Reliability", + "domain": "J", + "quotes": [], + "solution": "Establezca un protocolo claro para el manejo de pagos y asegúrese de que los clientes estén informados sobre los tiempos de procesamiento. Considere revisar su relación con las entidades bancarias para evitar futuros problemas y mejorar la experiencia del cliente.", + "primitive": "RELIABILITY", + "complexity": "medium", + "score_cost": 0.2, + "description": "Un cliente reportó un retraso en el pago, mencionando problemas con su banco en Alemania. Esto puede afectar la confianza en la fiabilidad de su servicio.", + "intensity_score": 3 + } + ], + "domain_overview": "El negocio muestra un sólido desempeño en la atención al cliente, con un 98% de satisfacción en el trato y el proceso. Sin embargo, el valor percibido y la equidad de precios presentan áreas de mejora, lo que sugiere una desconexión entre la experiencia del cliente y la percepción del valor ofrecido.", + "review_evidence": [], + "score_breakdown": { + "volume": 15.0, + "momentum": 0.0, + "intensity": 14.6, + "rating_quality": 29.3, + "sentiment_depth": 24.3 + }, + "matrix_narrative": "El análisis muestra un cuadrante urgente con alta frecuencia y baja satisfacción en 'Valor por Dinero', que incluye 5 menciones negativas sobre tasaciones bajas. Las quejas sobre la actitud del personal también requieren atención, aunque presentan una alta satisfacción general. Las áreas de menor frecuencia, como 'Fiabilidad', indican puntos ciegos que podrían beneficiarse de una mayor atención, dado su bajo número de menciones.", + "potential_rating": 5.0, + "rating_narrative": "La puntuación de reputación de 83/100 refleja una fuerte satisfacción entre los clientes, con 581 de 596 reseñas positivas. Sin embargo, la polarización se evidencia en las menciones negativas sobre la actitud del personal y la valoración del vehículo, lo que sugiere una brecha entre la experiencia del cliente y las expectativas. La tasa de respuesta del propietario es alta, pero la falta de momentum indica que hay oportunidades para mejorar la percepción del valor y la atención al cliente.", + "reputation_score": 83.3, + "themes_narrative": "Los clientes mencionan con mayor frecuencia la actitud del personal, con 462 menciones positivas y 9 negativas. La velocidad del proceso también destaca, con 129 comentarios positivos, mientras que la percepción del valor por dinero muestra una división, con 5 menciones negativas. La competencia del equipo se aprecia en 47 comentarios positivos, pero hay un pequeño número de quejas sobre la falta de atención y efectividad. Los clientes valoran positivamente el trato, pero hay preocupaciones sobre las tasaciones y el proceso de valoración.", + "trends_narrative": "Las calificaciones han mostrado una ligera mejoría en general, con un aumento notable en Q3 2024 y Q4 2025. Sin embargo, Q1 2026 presenta una caída, lo que sugiere una posible inestabilidad. La satisfacción en el trato y la rapidez en el proceso son los principales impulsores de la mejora, mientras que la percepción del valor por el dinero ha mostrado variaciones. Es importante observar la tendencia en el valor, que ha fluctuado y podría requerir atención.", + "staff_leaderboard": [], + "seasonal_narrative": "El patrón estacional muestra un rendimiento constante a lo largo del año, con calificaciones más altas en Q3. Esto es típico en el sector, pero la ligera caída en Q1 podría merecer una revisión para asegurar que no se convierta en un patrón problemático.", + "rating_distribution": { + "1": 10, + "2": 3, + "3": 2, + "4": 12, + "5": 569 + }, + "domain_sentiment_narrative": "La tendencia en el sentimiento de los dominios muestra una alta satisfacción en el trato y el proceso, pero el valor ha tenido altibajos, cayendo a un 60% positivo en Q2 2024 antes de mejorar nuevamente. Esta variabilidad en el valor podría ser un foco de atención.", + "rating_evolution_narrative": "La evolución de las calificaciones indica un aumento general, alcanzando un pico en Q3 2024. Sin embargo, la caída en Q1 2026 con un promedio de 4.75 sugiere una posible preocupación que necesita ser investigada." +}; \ No newline at end of file diff --git a/apps/web/src/modules/marketing/demo/report/ReputationBlueprint.tsx b/apps/web/src/modules/marketing/demo/report/ReputationBlueprint.tsx new file mode 100644 index 0000000..6da5175 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/ReputationBlueprint.tsx @@ -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(reportLang); + const reportRef = useRef(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 ( +
+ {/* Top Bar */} +
+
+

{report.business_name}

+

+ Reputation Blueprint · {report.report_date} +

+
+
+ {/* Language Selector */} +
+ + +
+
+
+ + {/* Report Content */} +
+ + + +
+
+
+
+ {hasTrends && } +
+
+ {hasStaff &&
} +
+
+ {hasConclusion && } + {hasReviewEvidence &&
} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/DomainRadar.tsx b/apps/web/src/modules/marketing/demo/report/charts/DomainRadar.tsx new file mode 100644 index 0000000..dfca1c4 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/DomainRadar.tsx @@ -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 ( +
+ No domain data +
+ ); + } + + const data = domains.map(d => ({ + domain: translateDomain(d.label, locale), + score: d.score, + fullMark: 100, + })); + + return ( + + + + + + + [`${value ?? 0}%`, 'Score']} + /> + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/DomainSentimentTrend.tsx b/apps/web/src/modules/marketing/demo/report/charts/DomainSentimentTrend.tsx new file mode 100644 index 0000000..6408aa8 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/DomainSentimentTrend.tsx @@ -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 = { + 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 ( +
+ No domain sentiment data +
+ ); + } + + // Detect which domains have data + const activeDomains = (Object.keys(DOMAIN_CONFIG) as Array).filter( + d => data.some(p => (p as any)[d] !== undefined && (p as any)[d] !== null) + ); + + return ( + + + + + + {activeDomains.map(d => ( + + ))} + [`${Number(value).toFixed(1)}%`, name ?? '']} + /> + + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/IntensityHeatmap.tsx b/apps/web/src/modules/marketing/demo/report/charts/IntensityHeatmap.tsx new file mode 100644 index 0000000..7374ee6 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/IntensityHeatmap.tsx @@ -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 ( +
+ No intensity data +
+ ); + } + + 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 ( +
+ + + + + + + + + + + + {displayed.map((t) => ( + + + {[t.intensity.i1, t.intensity.i2, t.intensity.i3].map((val, i) => ( + + ))} + + + ))} + +
PrimitiveI1 (Low)I2 (Med)I3 (High)Total
+ {t.label} + + + {val} + + + {t.count} +
+
+ Low +
+ {['#fef3c7', '#f59e0b', '#f97316', '#ef4444'].map((c, i) => ( +
+ ))} +
+ High +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/MomentumDual.tsx b/apps/web/src/modules/marketing/demo/report/charts/MomentumDual.tsx new file mode 100644 index 0000000..4c687f3 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/MomentumDual.tsx @@ -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 ( +
+ No momentum data +
+ ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/QuarterlyRatingChart.tsx b/apps/web/src/modules/marketing/demo/report/charts/QuarterlyRatingChart.tsx new file mode 100644 index 0000000..8a4c1c1 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/QuarterlyRatingChart.tsx @@ -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 ( +
+ No quarterly data +
+ ); + } + + const chartData = data.map(p => ({ + quarter: p.quarter, + avg_rating: p.avg_rating, + review_count: p.review_count, + })); + + return ( + + + + + + + { + const { cx, cy, payload } = props; + const r = Math.max(3, Math.min(8, Math.sqrt(payload.review_count) * 1.5)); + return ; + }} + name="Avg Rating" + /> + [`${Number(value).toFixed(2)} / 5`, 'Rating']} + labelFormatter={(label: string) => label} + /> + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/RatingTrend.tsx b/apps/web/src/modules/marketing/demo/report/charts/RatingTrend.tsx new file mode 100644 index 0000000..b5f6cb1 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/RatingTrend.tsx @@ -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 ( +
+ No trend data +
+ ); + } + + 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 ( + + + + + + + + {projection && ( + + )} + { + if (value === undefined || value === null) return ['-', name]; + return [`${Number(value).toFixed(2)} / 5`, name === 'Projected' ? 'Projected' : 'Rating']; + }} + /> + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/ReputationScoreGauge.tsx b/apps/web/src/modules/marketing/demo/report/charts/ReputationScoreGauge.tsx new file mode 100644 index 0000000..38cb402 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/ReputationScoreGauge.tsx @@ -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 ( +
+
+ + + + + + +
+ {clampedScore} + {t('out_of_100')} + {bandLabel} +
+
+ {/* 5-band scale bar */} +
+
+
+
+
+
+
+
+ {/* Score position indicator */} +
+
+
+ {/* Band labels */} +
+ {(() => { + 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) => ( + {lbl} + )); + })()} +
+
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/SeasonalPatternChart.tsx b/apps/web/src/modules/marketing/demo/report/charts/SeasonalPatternChart.tsx new file mode 100644 index 0000000..a90b2fc --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/SeasonalPatternChart.tsx @@ -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 ( +
+ No seasonal data +
+ ); + } + + 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 ( + + + + + + {overallAvg > 0 && ( + + )} + + Number(v).toFixed(2)} style={{ fill: '#374151', fontSize: 11, fontWeight: 500 }} /> + + { + if (name === 'avg_rating') return [`${Number(value).toFixed(2)} / 5`, 'Rating']; + return [value, name]; + }} + labelFormatter={(label: any) => String(label)} + /> + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/SentimentDonut.tsx b/apps/web/src/modules/marketing/demo/report/charts/SentimentDonut.tsx new file mode 100644 index 0000000..f53da4f --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/SentimentDonut.tsx @@ -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 ( +
+ No sentiment data +
+ ); + } + + return ( +
+
+ + + {data.map((entry, i) => ( + + ))} + + [(value ?? 0).toLocaleString(), 'Mentions']} + contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb' }} + /> + +
+
+ {data.map((entry) => ( +
+
+
+
+ {entry.name} +
+
+ {((entry.value / total) * 100).toFixed(0)}% · {entry.value} +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/charts/ThemeMatrix.tsx b/apps/web/src/modules/marketing/demo/report/charts/ThemeMatrix.tsx new file mode 100644 index 0000000..72d5876 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/charts/ThemeMatrix.tsx @@ -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(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 ( +
+ No theme data +
+ ); + } + + // 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 ( + + {value} + + ); + }; + + // 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 ( +
+ + + {/* Gradient background zones */} + + + + + + + + + + + + + + + + + + { + 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 ( +
+
{point.name}
+
+ {locale === 'es' ? 'Menciones' : 'Mentions'}: {point.x} +
+
+ {locale === 'es' ? 'Sentimiento Neto' : 'Net Sentiment'}: {point.y}% +
+
+ ); + }} + /> + + {data.map((entry, i) => ( + + ))} + + +
+
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/components/ReportLogo.tsx b/apps/web/src/modules/marketing/demo/report/components/ReportLogo.tsx new file mode 100644 index 0000000..da1f4ef --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/components/ReportLogo.tsx @@ -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 = () => ( + + + + + + + + + {isDark && } + + + + + + + + + + + + ); + + const Wordmark = () => ( + + whyrating.com + + ); + + const Tagline = () => ( + + The story behind your stars + + ); + + const containerStyle: React.CSSProperties = { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', + padding: calc.clearSpace, + }; + + if (variant === 'icon') { + return
; + } + + if (variant === 'primary') { + return ( +
+ +
+ +
+ ); + } + + if (variant === 'full') { + return ( +
+ +
+ +
+ +
+ ); + } + + if (variant === 'horizontal') { + return ( +
+ + +
+ ); + } + + return null; +} + +// === REPORT LOGO WRAPPER === + +interface ReportLogoProps { + variant: 'header' | 'cover' | 'footer'; +} + +export default function ReportLogo({ variant }: ReportLogoProps) { + if (variant === 'cover') { + return ( +
+ + + Reputation Intelligence + +
+ ); + } + + const size = variant === 'header' ? 80 : 60; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/components/ReportPage.tsx b/apps/web/src/modules/marketing/demo/report/components/ReportPage.tsx new file mode 100644 index 0000000..22e9bd2 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/components/ReportPage.tsx @@ -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 ( +
+
+ {showHeader && background !== 'dark' && ( +
+
+ +
+
+ + {pageNumber} / {totalPages} + +
+
+ )} +
+ {children} +
+ {background !== 'dark' && ( +
+ + + Confidential — Prepared by whyrating.com + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/components/SectionHeader.tsx b/apps/web/src/modules/marketing/demo/report/components/SectionHeader.tsx new file mode 100644 index 0000000..3d96e28 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/components/SectionHeader.tsx @@ -0,0 +1,17 @@ +'use client'; + +interface SectionHeaderProps { + number: number | string; + title: string; + subtitle?: string; +} + +export default function SectionHeader({ number, title, subtitle }: SectionHeaderProps) { + return ( +
+
{number}
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/i18n/contentTranslations.ts b/apps/web/src/modules/marketing/demo/report/i18n/contentTranslations.ts new file mode 100644 index 0000000..6d63f8f --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/i18n/contentTranslations.ts @@ -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> = { + 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> = { + 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> = { + 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> = { + es: { + high: 'alto', medium: 'medio', low: 'bajo', + High: 'Alto', Medium: 'Medio', Low: 'Bajo', + }, +}; + +// Common timeline phrases +const TIMELINE_PHRASES: Record> = { + 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; +} diff --git a/apps/web/src/modules/marketing/demo/report/i18n/translations.ts b/apps/web/src/modules/marketing/demo/report/i18n/translations.ts new file mode 100644 index 0000000..3aedf19 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/i18n/translations.ts @@ -0,0 +1,418 @@ +export const translations: Record> = { + 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', + }, +}; diff --git a/apps/web/src/modules/marketing/demo/report/i18n/useReportLocale.ts b/apps/web/src/modules/marketing/demo/report/i18n/useReportLocale.ts new file mode 100644 index 0000000..da59904 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/i18n/useReportLocale.ts @@ -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 }; +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/ActionPlan.tsx b/apps/web/src/modules/marketing/demo/report/sections/ActionPlan.tsx new file mode 100644 index 0000000..3cbeba4 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/ActionPlan.tsx @@ -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(); + 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 ( + + {pageIdx === 0 ? ( + + ) : ( +
+ {t('action_plan')} ({t('continued')}) +
+ )} + +
+ {pageItems.map((action, i) => ( + + ))} +
+
+ ); + })} + + ); +} + +function ActionCard({ action, index, t, locale, mentionCount }: { action: ActionItem; index: number; t: (key: string) => string; locale: string; mentionCount?: number }) { + return ( +
+
{index + 1}
+
+
+
+
{action.action}
+
+ {t('owner')}: {action.owner} · {t('source')}: {translatePrimitive(action.source, locale)} + {mentionCount != null && ( + ({mentionCount} {t('mentions')}) + )} + {' '}· {translateTimeline(action.timeline, locale)} +
+
+
+ {translateLevel(action.effort, locale)} {t('effort')} + {translateLevel(action.impact, locale)} {t('impact')} +
+
+ {action.success_metric && ( +
+ {t('success_metric')}: {action.success_metric} +
+ )} + {action.detail && ( +

+ {action.detail} +

+ )} + {action.evidence && ( +
+ “{action.evidence}” +
+ )} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/CoverPage.tsx b/apps/web/src/modules/marketing/demo/report/sections/CoverPage.tsx new file mode 100644 index 0000000..3eac8d9 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/CoverPage.tsx @@ -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 ( + +
+ {/* Logo */} + + + {/* Business Name */} +

+ {report.business_name} +

+ + {/* Category badge */} + {report.category_label && ( + + {report.category_label} + + )} + + {/* Date */} +

+ {new Date(report.report_date).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' })} +

+ + {/* Reporting Period */} + {report.methodology?.oldest_review && report.methodology?.newest_review && ( +

+ {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' })} +

+ )} + +
+ + {/* Score Gauge */} +
+ +
+ + {/* Feature Pills */} +
+ {report.review_count.toLocaleString()} {t('reviews_analyzed')} + {t('reputation_score')}: {Math.round(score)} + {report.critical_issues.length} {t('critical_issues_count')} +
+ + {/* Stats Row */} +
+
+
{report.current_rating.toFixed(1)}
+
{t('current_rating')}
+
+
+
{report.potential_rating.toFixed(1)}
+
{t('potential_rating')}
+
+
+
{report.review_count.toLocaleString()}
+
{t('reviews_analyzed')}
+
+
+ + {/* Confidentiality */} +

+ {t('confidential_footer')} +

+
+ + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/CriticalIssues.tsx b/apps/web/src/modules/marketing/demo/report/sections/CriticalIssues.tsx new file mode 100644 index 0000000..886ae8b --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/CriticalIssues.tsx @@ -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 = { + 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) => ( + + {pageIdx === 0 ? ( + + ) : ( +
+ {t('critical_issues')} ({t('continued')}) +
+ )} + +
+ {pageItems.map((issue, idx) => { + const globalIdx = pageIdx === 0 ? idx : pages[0]!.length + (pageIdx - 1) * 3 + idx; + return ( +
+
{globalIdx + 1}
+
+
{translateThemeLabel(issue.title, locale)}
+
+ + + {translatePrimitive(issue.primitive, locale)} + + + {issue.count} {t('complaints')} + + {issue.score_cost != null && issue.score_cost > 0 && ( + + −{issue.score_cost} {t('score_cost_pts')} + + )} + + {t(COMPLEXITY_I18N[issue.complexity] || 'moderate')} + +
+ + {issue.description && ( +

{issue.description}

+ )} + + {issue.quotes.length > 0 && ( +
+
{t('evidence')}
+ {issue.quotes.slice(0, 2).map((quote, qi) => ( +
+ “{quote}” +
+ ))} +
+ )} + + {issue.solution && ( +
+
{t('recommended_solution')}
+

{issue.solution}

+
+ )} +
+
+ ); + })} +
+
+ ))} + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/DomainPerformance.tsx b/apps/web/src/modules/marketing/demo/report/sections/DomainPerformance.tsx new file mode 100644 index 0000000..db8e902 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/DomainPerformance.tsx @@ -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 ( + + + + {report.domain_overview && ( +
+

+ {report.domain_overview} +

+
+ )} + + {/* Two-column: Radar + Score Bars */} +
+
+ +
+ +
+
+ {sorted.map((d) => ( +
+ + + {translateDomain(d.label, locale)} + +
+
+
+ {d.score}% +
+ ))} +
+
+
+ + {/* Domain Detail Cards */} +
+ {domains.map((d) => ( +
+
+ + {translateDomain(d.label, locale)} +
+
{d.score}%
+
+ {d.weight}% {t('domain_weight_suffix')} · {d.primitives.length} {t('domain_aspects_suffix')} +
+ {d.narrative && ( +

+ {d.narrative} +

+ )} +
+ ))} +
+ + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/EndPage.tsx b/apps/web/src/modules/marketing/demo/report/sections/EndPage.tsx new file mode 100644 index 0000000..3e10bf4 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/EndPage.tsx @@ -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 ( + +
+ {/* Title */} +
+

+ {t('end_page_title')} +

+
+ + {/* Key Takeaways */} + {conclusion.takeaways.length > 0 && ( +
+

+ {t('key_takeaways')} +

+
    + {conclusion.takeaways.map((item, i) => ( +
  1. + {item} +
  2. + ))} +
+
+ )} + + {/* 90-Day Focus */} + {conclusion.ninety_day_focus && ( +
+

+ {t('ninety_day_focus')} +

+

+ {conclusion.ninety_day_focus} +

+
+ )} + + {/* Review Cadence */} + {conclusion.review_cadence && ( +
+

+ {t('review_cadence')} +

+

+ {conclusion.review_cadence} +

+
+ )} + +
+ + {/* Cost of Inaction */} + {conclusion.cost_of_inaction && ( +
+

+ {t('cost_of_inaction')} +

+

+ {conclusion.cost_of_inaction} +

+
+ )} + + {/* Spacer */} +
+ +
+ + {/* Branding */} +
+
+

+ whyrating.com +

+

{t('reputation_intelligence')}

+

{t('view_dashboard')}

+
+
+ + {/* Confidentiality */} +

+ {t('confidential_footer')} +

+
+ + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/ExecutiveSummary.tsx b/apps/web/src/modules/marketing/demo/report/sections/ExecutiveSummary.tsx new file mode 100644 index 0000000..b84f3c9 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/ExecutiveSummary.tsx @@ -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 = { + 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 ( + + + + {/* Verdict */} + {report.verdict && ( +
+

{report.verdict}

+
+ )} + + {/* Score Breakdown */} +
+

{t('score_breakdown')}

+
+ {PILLAR_KEYS.map((key) => { + const value = breakdown[key] ?? 0; + const max = PILLAR_MAX[key] || 25; + const pct = (value / max) * 100; + return ( +
+ {t(`pillar_${key}`)} +
+
+
+ {value.toFixed(1)}/{max} +
+ ); + })} +
+
+ + {/* Key Findings */} + {report.key_findings.length > 0 && ( +
+

{t('key_findings')}

+
+ {report.key_findings.map((finding, i) => ( +
+
{i + 1}
+

{finding}

+
+ ))} +
+
+ )} + + {/* Revenue Impact */} + {report.revenue_impact && ( +
+
{t('revenue_impact')}
+

{report.revenue_impact}

+
+ )} + + {/* 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 ( +
+
+ {t('score_cost')} + −{totalCost.toFixed(1)} pts +
+
+ {costIssues.map((ci, i) => ( +
+ −{ci.score_cost?.toFixed(1)} {ci.title} +
+ ))} +
+
+ ); + })()} + + {/* Top Actions Preview */} + {topActions.length > 0 && ( +
+

{t('top_priority_actions')}

+
+ {topActions.map((action, i) => ( +
+
{i + 1}
+
+
{action.action}
+
+ {action.owner} · {translateTimeline(action.timeline, locale)} · {translateLevel(action.impact, locale)} {t('impact')} +
+
+
+ ))} +
+
+ )} + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/HowToRead.tsx b/apps/web/src/modules/marketing/demo/report/sections/HowToRead.tsx new file mode 100644 index 0000000..d296ec4 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/HowToRead.tsx @@ -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 ( + +
+

+ {t('how_to_read')} +

+

+ {t('how_to_read_lead')} +

+ + {/* Score Spectrum */} +
+

{t('how_to_read_score_title')}

+
+ {SCORE_BANDS_VISUAL.map(band => ( +
+ {t(band.key)} +
+ ))} +
+
+ 0 + 40 + 60 + 75 + 90 + 100 +
+

{t('how_to_read_score_desc')}

+
+ + {/* 2x2 Grid */} +
+ {/* Card 1: Sentiment Markers */} +
+

{t('how_to_read_valence_title')}

+

{t('how_to_read_valence_desc')}

+
+ {VALENCE_MARKERS.map(v => ( +
+ + {v.symbol} + + {t(v.key)} +
+ ))} +
+
+ + {/* Card 2: Experience Domains */} +
+

{t('how_to_read_domains_title')}

+

{t('how_to_read_domains_desc')}

+
+ {DOMAINS.map(d => ( +
+ + {d.code} + + {t(d.key)} +
+ ))} +
+
+ + {/* Card 3: Intensity Levels */} +
+

{t('how_to_read_intensity_title')}

+

{t('how_to_read_intensity_desc')}

+
+ {INTENSITY_LEVELS.map(il => ( +
+
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ {t(il.key)} +
+ ))} +
+
+ + {/* Card 4: Reading Tips */} +
+

{t('how_to_read_tips_title')}

+
+ {[1, 2, 3, 4].map(i => ( +
+ + {i}. + + + {t(`how_to_read_tip_${i}`)} + +
+ ))} +
+
+
+
+ + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/RatingDashboard.tsx b/apps/web/src/modules/marketing/demo/report/sections/RatingDashboard.tsx new file mode 100644 index 0000000..bf75413 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/RatingDashboard.tsx @@ -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 ( + + + + {/* Stats Row */} +
+
+
{report.current_rating.toFixed(1)}
+
{t('current_rating')}
+
+
+
{report.potential_rating.toFixed(1)}
+
{t('potential_rating')}
+
+
+
{totalReviews.toLocaleString()}
+
{t('reviews_analyzed')}
+
+
+
{Math.round(report.reputation_score)}
+
{t('reputation_score')}
+
+
+ + {/* Rating Narrative */} + {report.rating_narrative && ( +
+

+ {report.rating_narrative} +

+
+ )} + + {/* Two-column layout: Distribution + Sentiment */} +
+ {/* Rating Distribution */} +
+

{t('distribution')}

+
+ {distEntries.map((item) => { + const pct = totalReviews > 0 ? (item.count / totalReviews) * 100 : 0; + return ( +
+ {item.star}★ +
+
+
+ {pct.toFixed(0)}% +
+ ); + })} +
+
+ + {/* Sentiment Donut */} + {(positive + negative + neutral + mixed) > 0 && ( +
+

{t('sentiment_distribution')}

+ +
+ )} +
+ + {/* Rating Trend */} + {report.charts?.rating_trend && report.charts.rating_trend.length > 0 && ( +
+

{t('rating_trend')}

+ +
+ )} + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/ReviewEvidence.tsx b/apps/web/src/modules/marketing/demo/report/sections/ReviewEvidence.tsx new file mode 100644 index 0000000..054d473 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/ReviewEvidence.tsx @@ -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 {text}; + } + + 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({text.slice(cursor, start)}); + } + + // Anchor span + const valenceColorMap: Record = { + '+': '#DCFCE7', + '-': '#FEE2E2', + '±': '#FEF3C7', + '0': '#F3F4F6', + }; + const bgColor = valenceColorMap[anchor.valence] || '#F3F4F6'; + const borderColor = getValenceColor(anchor.valence); + + parts.push( + + {text.slice(start, Math.min(end, text.length))} + + ); + cursor = Math.min(end, text.length); + } + + // Remaining text + if (cursor < text.length) { + parts.push({text.slice(cursor)}); + } + + return <>{parts}; +} + +function StarRating({ rating }: { rating: number }) { + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + ); +} + +// 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) => ( + + {pageIdx === 0 ? ( + + ) : ( +
+ {t('appendix_review_evidence')} ({t('continued')}) +
+ )} + +
+ {pageItems.map((review, idx) => ( + + ))} +
+
+ ))} + + ); +} + +function ReviewCard({ + review, + locale, + t, +}: { + review: ReviewEvidenceType; + locale: string; + t: (key: string) => string; +}) { + return ( +
+ {/* Header: author + rating + date */} +
+
+ + {review.author || t('anonymous')} + + {review.rating && } + {review.date && ( + + {isNaN(Date.parse(review.date)) ? review.date : new Date(review.date).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' })} + + )} +
+
+ + {/* Highlighted review text */} +
+ +
+ + {/* Classification tags */} + {review.classifications.length > 0 && ( +
+ {review.classifications.map((cls, i) => { + const color = getValenceColor(cls.valence); + const bgMap: Record = { '+': '#DCFCE7', '-': '#FEE2E2', '±': '#FEF3C7', '0': '#F3F4F6' }; + const textMap: Record = { '+': '#166534', '-': '#991B1B', '±': '#92400E', '0': '#6B7280' }; + const bg = bgMap[cls.valence] || '#F3F4F6'; + const textColor = textMap[cls.valence] || '#6B7280'; + return ( + + {translatePrimitive(cls.primitive, locale)} {cls.valence} + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/StaffLeaderboard.tsx b/apps/web/src/modules/marketing/demo/report/sections/StaffLeaderboard.tsx new file mode 100644 index 0000000..d182d34 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/StaffLeaderboard.tsx @@ -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 = { + 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 ( + + ); + } + + // Legacy array format + if (!Array.isArray(staff) || staff.length === 0) return null; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + + + {/* Top Performer Callout */} + {topPerformer && topPerformer.positive_quotes.length > 0 && ( +
+
+ + {t('staff_top_performer')}: {topPerformer.canonical_name} + +
+
+ “{topPerformer.positive_quotes[0]}” +
+
+ )} + + {/* Individuals Table */} + {individuals.length > 0 && ( + <> +

{t('staff_individuals')}

+ + + )} + + {/* Groups Table */} + {groups.length > 0 && ( + <> +

{t('staff_groups')}

+ + + )} + + {/* Observations */} + {resolved.observations && ( +
+

+ {resolved.observations} +

+
+ )} + + {/* Disclaimer */} +
+

+ {t('staff_disclaimer')} +

+
+
+ ); +} + +function ResolvedTable({ + members, + t, + showRole, +}: { + members: (StaffIndividual | StaffGroup)[]; + t: (key: string) => string; + showRole: boolean; +}) { + return ( + + + + + + {showRole && } + + + + + + + + {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 ( + + + + {showRole && } + + + + + + ); + })} + +
{t('staff_rank')}{t('staff_name')}{t('staff_role')}{t('staff_mentions')}{t('staff_sentiment')}{t('staff_positive')}{t('staff_negative')}
+ {rankStyle ? ( + + {rank} + + ) : ( + {rank} + )} + {member.canonical_name}{role || '\u2014'}{member.total_mentions} + = 70 ? 'green' : 'amber'}`}> + {sentimentScore}% + + + {member.positive} + + {member.negative} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {pageIdx === 0 ? ( + <> + + + {/* Top Performer Callout */} + {topPerformer && topPerformer.positive_quotes && topPerformer.positive_quotes.length > 0 && ( +
+
+ + {t('staff_top_performer')}: {topPerformer.name} + +
+
+ “{topPerformer.positive_quotes[0]}” +
+
+ )} + + ) : ( +
+ {t('staff_leaderboard')} ({t('continued')}) +
+ )} + + {/* Staff Table */} + + + {/* Evidence Quotes -- only on the last page */} + {isLastPage && ( + <> + +
+

+ {t('staff_disclaimer')} +

+
+ + )} +
+ ); + })} + + ); +} + +function LegacyStaffTable({ staff, globalOffset, t }: { staff: StaffMember[]; globalOffset: number; t: (key: string) => string }) { + return ( + + + + + + + + + + + + + {staff.map((member, i) => { + const rank = globalOffset + i + 1; + const rankStyle = RANK_COLORS[rank]; + const sentimentScore = member.sentiment_score ?? 0; + + return ( + + + + + + + + + ); + })} + +
{t('staff_rank')}{t('staff_name')}{t('staff_mentions')}{t('staff_sentiment')}{t('staff_positive')}{t('staff_negative')}
+ {rankStyle ? ( + + {rank} + + ) : ( + {rank} + )} + {member.name}{member.total_mentions} + = 70 ? 'green' : 'amber'}`}> + {sentimentScore}% + + + {member.positive} + + {member.negative} +
+ ); +} + +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 ( +
+
+ {t('evidence')} +
+ {staff.map((member) => { + const quotes = member.positive_quotes?.slice(0, 1) || []; + if (quotes.length === 0) return null; + return ( +
+ + {member.name}: + + {quotes.map((q, qi) => ( +
+ “{q}” +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/StrengthsToProtect.tsx b/apps/web/src/modules/marketing/demo/report/sections/StrengthsToProtect.tsx new file mode 100644 index 0000000..0683f62 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/StrengthsToProtect.tsx @@ -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) => ( + + {pageIdx === 0 ? ( + + ) : ( +
+ {t('protect_strengths')} ({t('continued')}) +
+ )} + +
+ {pageItems.map((strength, idx) => { + const globalIdx = pageIdx === 0 ? idx : pages[0]!.length + (pageIdx - 1) * 3 + idx; + return ( +
+
{globalIdx + 1}
+
+
+
{translateThemeLabel(strength.title, locale)}
+ + {strength.count} {t('mentions')} + +
+
+ + {translatePrimitive(strength.primitive, locale)} +
+ + {strength.description && ( +

{strength.description}

+ )} + + {strength.quotes.length > 0 && ( +
+
{t('customer_voices')}
+ {strength.quotes.slice(0, 2).map((quote, qi) => ( +
+ “{quote}” +
+ ))} +
+ )} + + {strength.marketing_angle && ( +
+
{t('marketing_angle')}
+

{strength.marketing_angle}

+
+ )} +
+
+ ); + })} +
+
+ ))} + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/TableOfContents.tsx b/apps/web/src/modules/marketing/demo/report/sections/TableOfContents.tsx new file mode 100644 index 0000000..bda250f --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/TableOfContents.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/ThemeAnalysis.tsx b/apps/web/src/modules/marketing/demo/report/sections/ThemeAnalysis.tsx new file mode 100644 index 0000000..442db0b --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/ThemeAnalysis.tsx @@ -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 ( + + + + {/* Theme Narrative */} + {report.themes_narrative && ( +

+ {report.themes_narrative} +

+ )} + + {/* Theme Matrix Chart */} +
+

{t('frequency_vs_sentiment')}

+ {report.matrix_narrative && ( +

+ {report.matrix_narrative} +

+ )} + +
+ + {/* Two-column: Pain Points + What Customers Love */} +
+ {/* Negative */} + {negativeThemes.length > 0 && ( +
+

{t('pain_points')}

+ {negativeThemes.map((theme) => ( +
+ +
+
{translateThemeLabel(theme.label, locale)}
+
+ {theme.valence.negative} {t('complaints')} +
+ {theme.top_quotes.negative.length > 0 && ( +
+ “{theme.top_quotes.negative[0]}” +
+ )} +
+
+ ))} +
+ )} + + {/* Positive */} + {positiveThemes.length > 0 && ( +
+

{t('what_customers_love')}

+ {positiveThemes.map((theme) => ( +
+ +
+
{translateThemeLabel(theme.label, locale)}
+
+ {theme.valence.positive} {t('mentions')} +
+ {theme.top_quotes.positive.length > 0 && ( +
+ “{theme.top_quotes.positive[0]}” +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/TrackingFramework.tsx b/apps/web/src/modules/marketing/demo/report/sections/TrackingFramework.tsx new file mode 100644 index 0000000..d17d283 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/TrackingFramework.tsx @@ -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 ( + + + + + + + + + + + + + + {kpis.map((kpi, i) => ( + + + + + + + ))} + +
{t('metric')}{t('current')}{t('target_30d')}{t('target_90d')}
+ {kpi.metric} + + {kpi.current} + + {kpi.target_30d} + + {kpi.target_90d} +
+
+ ); +} diff --git a/apps/web/src/modules/marketing/demo/report/sections/TrendsTimeline.tsx b/apps/web/src/modules/marketing/demo/report/sections/TrendsTimeline.tsx new file mode 100644 index 0000000..d464340 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/sections/TrendsTimeline.tsx @@ -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: () => ( +
+

+ {t('quarterly_rating_evolution')} +

+ + {report.rating_evolution_narrative && ( +

+ {report.rating_evolution_narrative} +

+ )} +
+ ), + }); + } + + if (domainSentiment.length > 1) { + charts.push({ + key: 'domain_sentiment', + render: () => ( +
+

+ {t('domain_sentiment_trend')} +

+ + {report.domain_sentiment_narrative && ( +

+ {report.domain_sentiment_narrative} +

+ )} +
+ ), + }); + } + + if (seasonal.length > 1) { + charts.push({ + key: 'seasonal', + render: () => ( +
+

+ {t('seasonal_pattern')} +

+ + {report.seasonal_narrative && ( +

+ {report.seasonal_narrative} +

+ )} +
+ ), + }); + } + + // 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) => ( + + {pageIdx === 0 ? ( + <> + + {report.trends_narrative && ( +
+

+ {report.trends_narrative} +

+
+ )} + + ) : ( +
+ {t('trends_timeline')} ({t('continued')}) +
+ )} + + {pageCharts.map((chart) => ( +
{chart.render()}
+ ))} +
+ ))} + + ); +} diff --git a/apps/web/src/modules/marketing/demo/report/styles/report-brand.css b/apps/web/src/modules/marketing/demo/report/styles/report-brand.css new file mode 100644 index 0000000..338595c --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/styles/report-brand.css @@ -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; +} diff --git a/apps/web/src/modules/marketing/demo/report/styles/report-theme.ts b/apps/web/src/modules/marketing/demo/report/styles/report-theme.ts new file mode 100644 index 0000000..ee7cc91 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/styles/report-theme.ts @@ -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> = { + 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 = { + 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 = { + 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 = { + 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 = { + low: '#22c55e', + medium: '#f59e0b', + high: '#ef4444', +}; + +export function getEffortColor(effort: string): string { + return EFFORT_COLORS[effort] || EFFORT_COLORS.medium || '#f59e0b'; +} + +const QUADRANT_COLORS: Record = { + 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 = { + 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 +]; diff --git a/apps/web/src/modules/marketing/demo/report/types.ts b/apps/web/src/modules/marketing/demo/report/types.ts new file mode 100644 index 0000000..f5c5741 --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/types.ts @@ -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; + + // 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; + }; + + // Conclusion (v2.2.0+) + conclusion?: { + takeaways: string[]; + ninety_day_focus: string; + review_cadence: string; + cost_of_inaction: string; + }; + + // Pre-computed chart data + charts: ReportCharts; +} diff --git a/apps/web/src/modules/marketing/demo/report/utils/paginate.ts b/apps/web/src/modules/marketing/demo/report/utils/paginate.ts new file mode 100644 index 0000000..b40c1fd --- /dev/null +++ b/apps/web/src/modules/marketing/demo/report/utils/paginate.ts @@ -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(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); +} diff --git a/apps/web/src/modules/marketing/layout/header/header.tsx b/apps/web/src/modules/marketing/layout/header/header.tsx index 46610a9..bcd85a3 100644 --- a/apps/web/src/modules/marketing/layout/header/header.tsx +++ b/apps/web/src/modules/marketing/layout/header/header.tsx @@ -15,7 +15,7 @@ const links = [ }, { label: "marketing:demoLabel", - href: "/#report-preview", + href: pathsConfig.demo.report, }, { label: "billing:pricing.label", diff --git a/packages/i18n/src/translations/en/marketing.json b/packages/i18n/src/translations/en/marketing.json index 5218cf3..5bbc789 100644 --- a/packages/i18n/src/translations/en/marketing.json +++ b/packages/i18n/src/translations/en/marketing.json @@ -1,5 +1,9 @@ { "demoLabel": "Demo", + "demoPage": { + "ctaText": "Like what you see? Get yours", + "ctaButton": "Get your Blueprint" + }, "product": { "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." diff --git a/packages/i18n/src/translations/es/marketing.json b/packages/i18n/src/translations/es/marketing.json index f12c6fc..09232c7 100644 --- a/packages/i18n/src/translations/es/marketing.json +++ b/packages/i18n/src/translations/es/marketing.json @@ -1,5 +1,9 @@ { "demoLabel": "Demo", + "demoPage": { + "ctaText": "¿Te gusta lo que ves? Obtén el tuyo", + "ctaButton": "Obtén tu Radiografía" + }, "product": { "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."