feat: add themes scatter chart, HowToRead section, and fix 9 UI annotation tasks
- Add ThemesTab with recharts ScatterChart (domain-colored, labeled dots) - Add HowToRead standalone section (score spectrum, valence markers, domains, intensity, tips) - Add demo-report-data.ts with anonymized synthesis data - Fix report fan: use overflow-x-clip instead of overflow-hidden to preserve shadows - Fix HowItWorks: move connector lines behind cards, fix SVG gauge arc alignment - Fix ScoreTab: increase gauge spacing, add score band labels (0-100) - Fix testimonials: use overflow-clip to allow page scroll through section - Fix banner: increase logo size - Add EN/ES i18n keys for themes tab and HowToRead section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,39 +2,10 @@ import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
import { WhyRatingLogo } from "~/modules/common/whyrating-logo";
|
||||
import { CtaButton } from "~/modules/marketing/layout/cta-button";
|
||||
import { Section } from "~/modules/marketing/layout/section";
|
||||
|
||||
const ScoreGauge = () => (
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
{/* Background arc */}
|
||||
<path
|
||||
d="M8 36 A16 16 0 1 1 40 36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
opacity="0.3"
|
||||
/>
|
||||
{/* Filled arc (~83%) */}
|
||||
<path
|
||||
d="M8 36 A16 16 0 1 1 38.5 30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="24"
|
||||
y="30"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
83
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Banner = async () => {
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
return (
|
||||
@@ -42,7 +13,12 @@ export const Banner = async () => {
|
||||
id="banner"
|
||||
className="bg-primary text-primary-foreground !max-w-full gap-4 sm:gap-6 md:gap-8 lg:gap-10"
|
||||
>
|
||||
<ScoreGauge />
|
||||
<WhyRatingLogo
|
||||
colorScheme="dark"
|
||||
className="gap-3"
|
||||
iconClassName="h-16 w-16 sm:h-20 sm:w-20"
|
||||
wordmarkClassName="text-3xl sm:text-5xl"
|
||||
/>
|
||||
<h3 className="text-3xl leading-[0.95] font-semibold tracking-tighter text-balance md:text-4xl lg:text-5xl">
|
||||
{t("cta.question")}
|
||||
</h3>
|
||||
|
||||
102
apps/web/src/modules/marketing/home/demo-report-data.ts
Normal file
102
apps/web/src/modules/marketing/home/demo-report-data.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anonymized demo data for the marketing landing page.
|
||||
// Source: real compramostucoche.es report, anonymized as "Bistro El Sol".
|
||||
// Stripped: top_quotes, evidence, detail (contain real customer text).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DemoTheme {
|
||||
label: string;
|
||||
domain: string;
|
||||
count: number;
|
||||
weight: number;
|
||||
primitive: string;
|
||||
valence: { positive: number; negative: number; neutral: number; mixed: number };
|
||||
}
|
||||
|
||||
export interface DemoAction {
|
||||
action: string;
|
||||
owner: string;
|
||||
effort: "low" | "medium" | "high";
|
||||
impact: "low" | "medium" | "high";
|
||||
source: string;
|
||||
timeline: string;
|
||||
success_metric: string;
|
||||
}
|
||||
|
||||
// Top 15 themes (dropped tail with count ≤ 2)
|
||||
export const DEMO_THEMES: DemoTheme[] = [
|
||||
{ label: "Manner/Attitude", domain: "P", count: 471, weight: 1.0, primitive: "MANNER", valence: { positive: 462, negative: 9, neutral: 0, mixed: 0 } },
|
||||
{ label: "Speed/Wait", domain: "J", count: 129, weight: 1.0, primitive: "SPEED", valence: { positive: 129, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Competence", domain: "P", count: 47, weight: 1.0, primitive: "COMPETENCE", valence: { positive: 47, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Recommend", domain: "meta", count: 45, weight: 1.0, primitive: "RECOMMEND", valence: { positive: 45, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Price Fairness", domain: "V", count: 41, weight: 1.0, primitive: "PRICE_FAIRNESS", valence: { positive: 38, negative: 3, neutral: 0, mixed: 0 } },
|
||||
{ label: "Attentiveness", domain: "P", count: 27, weight: 1.0, primitive: "ATTENTIVENESS", valence: { positive: 26, negative: 1, neutral: 0, mixed: 0 } },
|
||||
{ label: "Value for Money", domain: "V", count: 24, weight: 1.0, primitive: "VALUE_FOR_MONEY", valence: { positive: 19, negative: 5, neutral: 0, mixed: 0 } },
|
||||
{ label: "Effectiveness", domain: "O", count: 23, weight: 1.0, primitive: "EFFECTIVENESS", valence: { positive: 22, negative: 1, neutral: 0, mixed: 0 } },
|
||||
{ label: "Friction", domain: "J", count: 16, weight: 1.0, primitive: "FRICTION", valence: { positive: 13, negative: 3, neutral: 0, mixed: 0 } },
|
||||
{ label: "Reliability", domain: "J", count: 15, weight: 1.0, primitive: "RELIABILITY", valence: { positive: 14, negative: 1, neutral: 0, mixed: 0 } },
|
||||
{ label: "Communication", domain: "P", count: 14, weight: 1.0, primitive: "COMMUNICATION", valence: { positive: 14, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Accuracy", domain: "O", count: 11, weight: 1.0, primitive: "ACCURACY", valence: { positive: 11, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Price Transparency", domain: "V", count: 8, weight: 1.0, primitive: "PRICE_TRANSPARENCY", valence: { positive: 8, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Honesty", domain: "meta", count: 6, weight: 1.0, primitive: "HONESTY", valence: { positive: 6, negative: 0, neutral: 0, mixed: 0 } },
|
||||
{ label: "Taste/Flavor", domain: "O", count: 5, weight: 1.0, primitive: "TASTE", valence: { positive: 4, negative: 1, neutral: 0, mixed: 0 } },
|
||||
];
|
||||
|
||||
// 5 anonymized actions (stripped evidence + detail)
|
||||
export const DEMO_ACTIONS: DemoAction[] = [
|
||||
{
|
||||
action: "Address staff attitude during assessments",
|
||||
owner: "Manager",
|
||||
effort: "medium",
|
||||
impact: "high",
|
||||
source: "MANNER",
|
||||
timeline: "This month",
|
||||
success_metric: "50% reduction in negative attitude mentions within 60 days",
|
||||
},
|
||||
{
|
||||
action: "Improve online process documentation",
|
||||
owner: "Manager",
|
||||
effort: "medium",
|
||||
impact: "medium",
|
||||
source: "FRICTION",
|
||||
timeline: "This month",
|
||||
success_metric: "80% of customers report a clearer process within 90 days",
|
||||
},
|
||||
{
|
||||
action: "Review and adjust pricing estimates",
|
||||
owner: "Manager",
|
||||
effort: "high",
|
||||
impact: "high",
|
||||
source: "VALUE_FOR_MONEY",
|
||||
timeline: "This quarter",
|
||||
success_metric: "30% increase in positive pricing mentions within 90 days",
|
||||
},
|
||||
{
|
||||
action: "Strengthen post-service communication",
|
||||
owner: "Manager",
|
||||
effort: "medium",
|
||||
impact: "high",
|
||||
source: "RELIABILITY",
|
||||
timeline: "This month",
|
||||
success_metric: "95% satisfaction in payment communication within 60 days",
|
||||
},
|
||||
{
|
||||
action: "Reinforce customer service training",
|
||||
owner: "Manager",
|
||||
effort: "medium",
|
||||
impact: "medium",
|
||||
source: "ATTENTIVENESS",
|
||||
timeline: "This month",
|
||||
success_metric: "90% positive customer attention mentions within 90 days",
|
||||
},
|
||||
];
|
||||
|
||||
// Domain color map (same as ThemeMatrix source)
|
||||
export const DOMAIN_COLORS: Record<string, string> = {
|
||||
P: "#3b82f6", // People/Service — blue
|
||||
J: "#f59e0b", // Journey/Process — amber
|
||||
V: "#f43f5e", // Value — rose
|
||||
O: "#22c55e", // Output/Product — green
|
||||
E: "#8b5cf6", // Environment — violet
|
||||
meta: "#6b7280", // Meta — gray
|
||||
};
|
||||
@@ -28,14 +28,14 @@ const GaugeMockup = () => (
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M6 32 A26 26 0 0 1 50 10"
|
||||
d="M6 32 A26 26 0 0 1 50.4 13.6"
|
||||
stroke="#4285F4"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex w-full items-center justify-center gap-1.5">
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
{["#3b82f6", "#22c55e", "#f59e0b"].map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
@@ -85,22 +85,27 @@ export const HowItWorks = async () => {
|
||||
</SectionHeader>
|
||||
|
||||
<div className="relative grid w-full max-w-5xl grid-cols-1 gap-8 md:grid-cols-3 md:gap-6 lg:gap-10">
|
||||
{/* Connector line behind all cards */}
|
||||
<div
|
||||
className="pointer-events-none absolute hidden md:block"
|
||||
style={{ top: "calc(1.5rem + 1.25rem)", left: "16.67%", right: "16.67%" }}
|
||||
>
|
||||
<div className="h-px w-full border-t-2 border-dashed border-muted-foreground/20" />
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const Mockup = stepMockups[index]!;
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-4 rounded-xl border bg-card p-6 text-center",
|
||||
"relative z-10 flex flex-col items-center gap-4 rounded-xl border bg-card p-6 text-center",
|
||||
"transition-all hover:-translate-y-1 hover:shadow-lg",
|
||||
)}
|
||||
>
|
||||
{/* Numbered circle + connector */}
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
{index < steps.length - 1 && (
|
||||
<div className="absolute top-1/2 left-[calc(50%+24px)] hidden h-px w-[calc(100%-24px)] border-t-2 border-dashed border-muted-foreground/20 md:block" />
|
||||
)}
|
||||
<div className="relative z-10 flex size-10 items-center justify-center rounded-full bg-[#4285F4] text-sm font-bold text-white">
|
||||
{/* Numbered circle */}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-[#4285F4] text-sm font-bold text-white">
|
||||
{step.number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
195
apps/web/src/modules/marketing/home/how-to-read.tsx
Normal file
195
apps/web/src/modules/marketing/home/how-to-read.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import {
|
||||
Section,
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCORE_BANDS = [
|
||||
{ range: "0–39", width: "40%", color: "#ef4444", key: "scoreCritical" },
|
||||
{ range: "40–59", width: "20%", color: "#f97316", key: "scorePoor" },
|
||||
{ range: "60–74", width: "15%", color: "#f59e0b", key: "scoreFair" },
|
||||
{ range: "75–89", width: "15%", color: "#22c55e", key: "scoreGood" },
|
||||
{ range: "90–100", width: "10%", color: "#059669", key: "scoreExcellent" },
|
||||
] as const;
|
||||
|
||||
const VALENCE_MARKERS = [
|
||||
{ symbol: "+", color: "#22c55e", bg: "bg-green-50 dark:bg-green-950/30", border: "border-green-300 dark:border-green-800", key: "valencePos" },
|
||||
{ symbol: "\u2212", color: "#ef4444", bg: "bg-red-50 dark:bg-red-950/30", border: "border-red-300 dark:border-red-800", key: "valenceNeg" },
|
||||
{ symbol: "0", color: "#9ca3af", bg: "bg-gray-50 dark:bg-gray-900/30", border: "border-gray-300 dark:border-gray-700", key: "valenceNeu" },
|
||||
{ symbol: "\u00b1", color: "#f59e0b", bg: "bg-amber-50 dark:bg-amber-950/30", border: "border-amber-300 dark:border-amber-800", key: "valenceMix" },
|
||||
] as const;
|
||||
|
||||
const EXPERIENCE_DOMAINS = [
|
||||
{ code: "O", color: "#3b82f6", key: "domainO" },
|
||||
{ code: "P", color: "#22c55e", key: "domainP" },
|
||||
{ code: "J", color: "#f59e0b", key: "domainJ" },
|
||||
{ code: "E", color: "#8b5cf6", key: "domainE" },
|
||||
{ code: "V", color: "#f43f5e", key: "domainV" },
|
||||
] as const;
|
||||
|
||||
const INTENSITY_LEVELS = [
|
||||
{ bars: 1, key: "intensity1" },
|
||||
{ bars: 2, key: "intensity2" },
|
||||
{ bars: 3, key: "intensity3" },
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HowToRead = () => {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
return (
|
||||
<Section id="how-to-read">
|
||||
<SectionHeader>
|
||||
<SectionBadge>{t("howToRead.label")}</SectionBadge>
|
||||
<SectionTitle>{t("howToRead.title")}</SectionTitle>
|
||||
<SectionDescription>{t("howToRead.description")}</SectionDescription>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="w-full max-w-3xl space-y-4">
|
||||
{/* Score Spectrum */}
|
||||
<div className="bg-muted/20 rounded-xl border p-5">
|
||||
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||
{t("howToRead.scoreTitle")}
|
||||
</h3>
|
||||
<div className="mb-1.5 flex h-7 overflow-hidden rounded-md">
|
||||
{SCORE_BANDS.map((band) => (
|
||||
<div
|
||||
key={band.range}
|
||||
className="flex items-center justify-center text-[9px] font-semibold text-white"
|
||||
style={{ width: band.width, backgroundColor: band.color }}
|
||||
>
|
||||
{t(`howToRead.${band.key}` as const)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-2 flex justify-between text-[9px]">
|
||||
<span>0</span>
|
||||
<span>40</span>
|
||||
<span>60</span>
|
||||
<span>75</span>
|
||||
<span>90</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("howToRead.scoreDesc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2x2 Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Sentiment Markers */}
|
||||
<div className="bg-muted/20 rounded-xl border p-5">
|
||||
<h3 className="text-foreground mb-1 text-sm font-bold">
|
||||
{t("howToRead.valenceTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-3 text-xs leading-relaxed">
|
||||
{t("howToRead.valenceDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{VALENCE_MARKERS.map((v) => (
|
||||
<div key={v.symbol} className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded border text-sm font-bold ${v.bg} ${v.border}`}
|
||||
style={{ color: v.color }}
|
||||
>
|
||||
{v.symbol}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`howToRead.${v.key}` as const)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experience Domains */}
|
||||
<div className="bg-muted/20 rounded-xl border p-5">
|
||||
<h3 className="text-foreground mb-1 text-sm font-bold">
|
||||
{t("howToRead.domainsTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-3 text-xs leading-relaxed">
|
||||
{t("howToRead.domainsDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{EXPERIENCE_DOMAINS.map((d) => (
|
||||
<div key={d.code} className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
|
||||
style={{ backgroundColor: d.color }}
|
||||
>
|
||||
{d.code}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`howToRead.${d.key}` as const)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intensity Levels */}
|
||||
<div className="bg-muted/20 rounded-xl border p-5">
|
||||
<h3 className="text-foreground mb-1 text-sm font-bold">
|
||||
{t("howToRead.intensityTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-3 text-xs leading-relaxed">
|
||||
{t("howToRead.intensityDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{INTENSITY_LEVELS.map((il) => (
|
||||
<div key={il.bars} className="flex items-center gap-2.5">
|
||||
<div className="flex w-6 shrink-0 justify-center gap-0.5">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3.5 w-[5px] rounded-sm"
|
||||
style={{
|
||||
backgroundColor: i < il.bars ? "#4285F4" : "#e2e8f0",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`howToRead.${il.key}` as const)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Tips */}
|
||||
<div className="bg-muted/20 rounded-xl border p-5">
|
||||
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||
{t("howToRead.tipsTitle")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{(["howToRead.tip1", "howToRead.tip2", "howToRead.tip3", "howToRead.tip4"] as const).map((key, i) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<span className="shrink-0 text-xs font-bold" style={{ color: "#4285F4" }}>
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t(key)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import Image from "next/image";
|
||||
|
||||
export const ReportFan = () => {
|
||||
return (
|
||||
<div className="animate-fade-in relative mx-auto mt-12 -translate-y-4 opacity-0 [--animation-delay:800ms] sm:mt-16 md:mt-20">
|
||||
<div className="animate-fade-in relative mx-auto mt-12 -translate-y-4 overflow-x-clip opacity-0 [--animation-delay:800ms] sm:mt-16 md:mt-20">
|
||||
{/* Ambient glow behind the fan */}
|
||||
<div className="pointer-events-none absolute inset-0 -inset-x-20 -bottom-10">
|
||||
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
||||
<div className="absolute top-1/2 left-1/2 h-[500px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
||||
<div className="absolute top-1/3 left-1/3 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ZAxis,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
|
||||
import { CtaButton } from "~/modules/marketing/layout/cta-button";
|
||||
import {
|
||||
@@ -12,6 +26,11 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
import {
|
||||
DEMO_THEMES,
|
||||
DEMO_ACTIONS,
|
||||
DOMAIN_COLORS as THEME_DOMAIN_COLORS,
|
||||
} from "./demo-report-data";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo data (hardcoded for "Bistro El Sol")
|
||||
@@ -44,14 +63,27 @@ const SEVERITY_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const EFFORT_COLORS: Record<string, string> = {
|
||||
low: "#22c55e",
|
||||
medium: "#f59e0b",
|
||||
high: "#ef4444",
|
||||
Low: "#22c55e",
|
||||
Medium: "#f59e0b",
|
||||
High: "#ef4444",
|
||||
Bajo: "#22c55e",
|
||||
Medio: "#f59e0b",
|
||||
Alto: "#ef4444",
|
||||
};
|
||||
|
||||
const IMPACT_COLORS: Record<string, string> = {
|
||||
low: "#6b7280",
|
||||
medium: "#f59e0b",
|
||||
high: "#22c55e",
|
||||
Low: "#6b7280",
|
||||
Medium: "#f59e0b",
|
||||
High: "#22c55e",
|
||||
Bajo: "#6b7280",
|
||||
Medio: "#f59e0b",
|
||||
Alto: "#22c55e",
|
||||
};
|
||||
|
||||
const DOMAIN_COLORS: Record<string, string> = {
|
||||
@@ -60,7 +92,7 @@ const DOMAIN_COLORS: Record<string, string> = {
|
||||
Environment: "#8b5cf6",
|
||||
};
|
||||
|
||||
const TABS = ["score", "domains", "issues", "actions"] as const;
|
||||
const TABS = ["score", "domains", "issues", "actions", "themes"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,16 +111,16 @@ function ScoreTab() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start sm:gap-10">
|
||||
{/* Left: Gauge + band */}
|
||||
<div className="flex shrink-0 flex-col items-center gap-2">
|
||||
<div className="flex shrink-0 flex-col items-center gap-3">
|
||||
<span className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("reportPreview.demo.businessName")}
|
||||
</span>
|
||||
|
||||
<div className="relative" style={{ width: 200, height: 120 }}>
|
||||
<div className="relative" style={{ width: 200, height: 130 }}>
|
||||
<svg
|
||||
width="200"
|
||||
height="120"
|
||||
viewBox="0 0 200 120"
|
||||
height="130"
|
||||
viewBox="0 0 200 130"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<circle
|
||||
@@ -118,7 +150,7 @@ function ScoreTab() {
|
||||
className="transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-end pb-3">
|
||||
<span
|
||||
className="text-6xl font-bold tabular-nums"
|
||||
style={{ color: SCORE_COLOR }}
|
||||
@@ -139,7 +171,7 @@ function ScoreTab() {
|
||||
<div style={{ width: "20%", backgroundColor: "#22c55e" }} />
|
||||
<div style={{ width: "40%", backgroundColor: "#059669" }} />
|
||||
</div>
|
||||
<div className="relative h-2.5">
|
||||
<div className="relative h-3">
|
||||
<div
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
@@ -153,6 +185,14 @@ function ScoreTab() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative -mt-0.5 flex text-[7px] text-muted-foreground">
|
||||
<span className="absolute left-0 -translate-x-1/2">0</span>
|
||||
<span className="absolute" style={{ left: "10%" }}>40</span>
|
||||
<span className="absolute" style={{ left: "25%" }}>60</span>
|
||||
<span className="absolute" style={{ left: "40%" }}>75</span>
|
||||
<span className="absolute" style={{ left: "60%" }}>90</span>
|
||||
<span className="absolute right-0 translate-x-1/2">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,59 +332,84 @@ function IssuesTab() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions Tab
|
||||
// Actions Tab (enhanced with real report data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ActionsTab() {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
const actions = [
|
||||
{ key: "action1" as const, idx: 0 },
|
||||
{ key: "action2" as const, idx: 1 },
|
||||
{ key: "action3" as const, idx: 2 },
|
||||
];
|
||||
// Build mention count map from demo themes
|
||||
const mentionMap = new Map<string, number>();
|
||||
for (const theme of DEMO_THEMES) {
|
||||
mentionMap.set(theme.primitive, theme.count);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{actions.map(({ key: actionKey, idx }) => {
|
||||
const effort = t(`reportPreview.demo.actions.${actionKey}.effort` as const);
|
||||
const impact = t(`reportPreview.demo.actions.${actionKey}.impact` as const);
|
||||
{DEMO_ACTIONS.map((action, idx) => {
|
||||
const effortColor = EFFORT_COLORS[action.effort] ?? "#f59e0b";
|
||||
const impactColor = IMPACT_COLORS[action.impact] ?? "#f59e0b";
|
||||
const mentions = mentionMap.get(action.source);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={actionKey}
|
||||
className="bg-muted/20 flex gap-3 rounded-lg border p-3"
|
||||
key={idx}
|
||||
className="bg-muted/20 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<div className="bg-primary/10 text-primary flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex gap-3 p-3">
|
||||
<div className="bg-primary/10 text-primary flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold">
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-semibold leading-tight">
|
||||
{t(`reportPreview.demo.actions.${actionKey}.title` as const)}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-semibold leading-tight">
|
||||
{action.action}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `${EFFORT_COLORS[effort] ?? "#f59e0b"}18`,
|
||||
color: EFFORT_COLORS[effort] ?? "#f59e0b",
|
||||
}}
|
||||
>
|
||||
{effort} effort
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `${IMPACT_COLORS[impact] ?? "#f59e0b"}18`,
|
||||
color: IMPACT_COLORS[impact] ?? "#f59e0b",
|
||||
}}
|
||||
>
|
||||
{impact} impact
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{action.owner}
|
||||
</span>
|
||||
{mentions != null && (
|
||||
<span className="text-xs font-medium" style={{ color: "#4285F4" }}>
|
||||
{mentions} {t("reportPreview.demo.themes.mentions" as const)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{action.timeline}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `${effortColor}18`,
|
||||
color: effortColor,
|
||||
}}
|
||||
>
|
||||
{action.effort} effort
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `${impactColor}18`,
|
||||
color: impactColor,
|
||||
}}
|
||||
>
|
||||
{action.impact} impact
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.success_metric && (
|
||||
<div className="bg-muted/30 border-t px-4 py-2">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<span className="font-semibold">Target:</span> {action.success_metric}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -352,6 +417,149 @@ function ActionsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Themes Tab (ThemeMatrix scatter chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScatterPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
name: string;
|
||||
domain: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function ThemesTab() {
|
||||
const { t } = useTranslation("marketing");
|
||||
|
||||
const data: ScatterPoint[] = DEMO_THEMES
|
||||
.filter((theme) => theme.domain !== "meta")
|
||||
.map((theme) => {
|
||||
const total =
|
||||
theme.valence.positive +
|
||||
theme.valence.negative +
|
||||
theme.valence.neutral +
|
||||
theme.valence.mixed;
|
||||
const sentimentRatio =
|
||||
total > 0
|
||||
? ((theme.valence.positive - theme.valence.negative) / total) * 100
|
||||
: 0;
|
||||
return {
|
||||
x: theme.count,
|
||||
y: Math.round(sentimentRatio),
|
||||
z: theme.weight * theme.count,
|
||||
name: theme.label,
|
||||
domain: theme.domain,
|
||||
color: THEME_DOMAIN_COLORS[theme.domain] ?? "#6b7280",
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.z - a.z);
|
||||
|
||||
const yValues = data.map((d) => d.y);
|
||||
const yMin = Math.min(-20, ...yValues) - 10;
|
||||
const yMax = Math.max(20, ...yValues) + 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Domain legend */}
|
||||
<div className="mb-4 flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||
{[
|
||||
{ label: "People/Service", domain: "P" },
|
||||
{ label: "Journey", domain: "J" },
|
||||
{ label: "Value", domain: "V" },
|
||||
{ label: "Product", domain: "O" },
|
||||
{ label: "Environment", domain: "E" },
|
||||
].map((d) => (
|
||||
<span key={d.domain} className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: THEME_DOMAIN_COLORS[d.domain] }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{d.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart margin={{ top: 20, right: 30, left: 10, bottom: 10 }}>
|
||||
<defs>
|
||||
<linearGradient id="tmPositiveZone" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.02} />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.08} />
|
||||
</linearGradient>
|
||||
<linearGradient id="tmNegativeZone" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.02} />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.08} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ReferenceArea y1={0} y2={yMax} fill="url(#tmPositiveZone)" strokeOpacity={0} />
|
||||
<ReferenceArea y1={yMin} y2={0} fill="url(#tmNegativeZone)" strokeOpacity={0} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
tick={{ fill: "#6b7280", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: t("reportPreview.demo.themes.frequency" as const),
|
||||
position: "insideBottom",
|
||||
offset: -5,
|
||||
fill: "#9ca3af",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
domain={[yMin, yMax]}
|
||||
tick={{ fill: "#6b7280", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{
|
||||
value: t("reportPreview.demo.themes.sentiment" as const),
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: "#9ca3af",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeWidth={1.5} />
|
||||
<ZAxis type="number" dataKey="z" range={[80, 500]} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = (payload[0] as Record<string, unknown>)
|
||||
?.payload as ScatterPoint | undefined;
|
||||
if (!point) return null;
|
||||
return (
|
||||
<div className="bg-background rounded-lg border p-2 shadow-md">
|
||||
<div className="text-foreground text-[13px] font-bold">
|
||||
{point.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("reportPreview.demo.themes.mentions" as const)}: <strong>{point.x}</strong>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("reportPreview.demo.themes.netSentiment" as const)}: <strong>{point.y}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={data}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} fillOpacity={0.7} />
|
||||
))}
|
||||
<LabelList dataKey="name" position="top" fontSize={9} fill="#6b7280" offset={8} />
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -372,7 +580,7 @@ export const ReportPreview = () => {
|
||||
|
||||
<div className="relative w-full max-w-3xl">
|
||||
{/* Ambient glow */}
|
||||
<div className="pointer-events-none absolute inset-0 -inset-x-20 -bottom-10">
|
||||
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
||||
<div className="absolute top-1/2 left-1/2 h-[400px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
||||
<div className="absolute top-1/3 left-1/3 h-[250px] w-[250px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||
</div>
|
||||
@@ -408,6 +616,7 @@ export const ReportPreview = () => {
|
||||
{activeTab === "domains" && <DomainsTab />}
|
||||
{activeTab === "issues" && <IssuesTab />}
|
||||
{activeTab === "actions" && <ActionsTab />}
|
||||
{activeTab === "themes" && <ThemesTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const Testimonials = () => {
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="relative flex h-[600px] grow flex-row items-center justify-center overflow-hidden lg:basis-0">
|
||||
<div className="relative flex h-[600px] grow flex-row items-center justify-center overflow-clip lg:basis-0">
|
||||
{rows.map((row, index) => (
|
||||
<Marquee
|
||||
key={index}
|
||||
|
||||
Reference in New Issue
Block a user