feat: redesign landing page with report-inspired visuals and conversion optimization

Add tabbed report preview (score gauge, domains, issues, actions), annotated
review evidence cards with sentiment highlights, social proof bar, and visual
how-it-works mockups. Enhance hero with stat pills, testimonials with stars,
and banner with mini gauge. Remove redundant features section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-22 16:01:49 +00:00
parent a73ddcb525
commit e0bf1b534b
11 changed files with 1101 additions and 99 deletions

View File

@@ -5,6 +5,36 @@ import { buttonVariants } from "@turbostarter/ui-web/button";
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 (
@@ -12,9 +42,13 @@ 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 />
<h3 className="text-3xl leading-[0.95] font-semibold tracking-tighter text-balance md:text-4xl lg:text-5xl">
{t("cta.question")}
</h3>
<p className="text-primary-foreground/80 text-sm sm:text-base">
{t("banner.urgency")}
</p>
<CtaButton className={cn(buttonVariants({ variant: "secondary" }))} />
</Section>
);

View File

@@ -1,12 +1,5 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { cn } from "@turbostarter/ui";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
import {
@@ -17,23 +10,67 @@ import {
SectionTitle,
} from "~/modules/marketing/layout/section";
const GaugeAccent = () => (
<svg width="48" height="28" viewBox="0 0 48 28" fill="none" className="mt-2">
<path
d="M4 24 A20 20 0 0 1 44 24"
stroke="#e5e7eb"
strokeWidth="4"
strokeLinecap="round"
fill="none"
/>
<path
d="M4 24 A20 20 0 0 1 38 8"
stroke="#4285F4"
strokeWidth="4"
strokeLinecap="round"
fill="none"
/>
</svg>
);
const domainDotColors = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#8b5cf6",
"#f43f5e",
"#6b7280",
];
const DomainsAccent = () => (
<div className="mt-2 flex items-center gap-1.5">
{domainDotColors.map((color) => (
<div
key={color}
className="size-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
))}
</div>
);
const BarsAccent = () => (
<div className="mt-2 flex items-end gap-1">
<div className="h-3 w-2 rounded-sm bg-[#4285F4]/60" />
<div className="h-5 w-2 rounded-sm bg-[#4285F4]/80" />
<div className="h-4 w-2 rounded-sm bg-[#4285F4]" />
</div>
);
const CheckAccent = () => (
<div className="mt-2 flex items-center gap-1">
<Icons.Check className="size-4 text-[#22c55e]" />
<Icons.Check className="size-4 text-[#22c55e]/60" />
<Icons.Check className="size-4 text-[#22c55e]/30" />
</div>
);
const features = [
{
key: "score",
icon: Icons.Target,
},
{
key: "domains",
icon: Icons.BarChart3,
},
{
key: "plan",
icon: Icons.Zap,
},
{
key: "insights",
icon: Icons.TrendingUp,
},
{ key: "score", Accent: GaugeAccent },
{ key: "domains", Accent: DomainsAccent },
{ key: "plan", Accent: BarsAccent },
{ key: "insights", Accent: CheckAccent },
] as const;
export const Features = async () => {
@@ -47,21 +84,23 @@ export const Features = async () => {
<SectionDescription>{t("features.description")}</SectionDescription>
</SectionHeader>
<div className="grid w-full max-w-5xl grid-cols-1 gap-6 sm:grid-cols-2">
<div className="grid w-full max-w-4xl grid-cols-2 gap-4 md:grid-cols-4 md:gap-6">
{features.map((feature) => (
<Card key={feature.key} className={cn("relative overflow-hidden")}>
<CardHeader>
<div className="bg-primary/10 text-primary mb-3 flex size-12 items-center justify-center rounded-xl">
<feature.icon className="size-6" />
</div>
<CardTitle className="text-lg">
{t(`features.feature.${feature.key}.title`)}
</CardTitle>
<CardDescription>
{t(`features.feature.${feature.key}.description`)}
</CardDescription>
</CardHeader>
</Card>
<div
key={feature.key}
className={cn(
"flex flex-col items-center rounded-xl border p-5 text-center",
"transition-all hover:border-[#4285F4]/20 hover:shadow-md",
)}
>
<span className="text-5xl font-bold tracking-tight text-[#4285F4]">
{t(`features.feature.${feature.key}.stat` as const)}
</span>
<feature.Accent />
<span className="mt-3 text-sm text-muted-foreground">
{t(`features.feature.${feature.key}.title` as const)}
</span>
</div>
))}
</div>
</Section>

View File

@@ -1,5 +1,7 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
@@ -11,7 +13,12 @@ export const Hero = async () => {
const { t } = await getTranslation();
return (
<Section id="hero" className="gap-6 sm:gap-6 md:gap-6 lg:gap-6">
<Section id="hero" className="relative gap-6 overflow-x-clip sm:gap-6 md:gap-6 lg:gap-6">
<GridPattern
width={48}
height={48}
className="opacity-[0.04] [mask-image:radial-gradient(600px_circle_at_center,white,transparent)]"
/>
<div className="animate-fade-in -translate-y-4 opacity-0">
<SectionBadge>
<div className="w-fit py-0.5 text-center text-xs sm:text-sm">
@@ -41,6 +48,20 @@ export const Hero = async () => {
{t("contact.cta")}
</TurboLink>
</div>
<div className="animate-fade-in flex -translate-y-4 flex-wrap items-center justify-center gap-3 opacity-0 [--animation-delay:700ms]">
<span className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs sm:text-sm">
<Icons.Star className="size-3.5 fill-yellow-500 text-yellow-500" />
{t("hero.stats.reviews")}
</span>
<span className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs sm:text-sm">
<Icons.Target className="size-3.5" />
{t("hero.stats.score")}
</span>
<span className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs sm:text-sm">
<Icons.AlertTriangle className="size-3.5" />
{t("hero.stats.issues")}
</span>
</div>
<ReportFan />
</Section>
);

View File

@@ -1,6 +1,6 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-web/icons";
import { MapPin } from "lucide-react";
import {
Section,
@@ -10,22 +10,67 @@ import {
SectionTitle,
} from "~/modules/marketing/layout/section";
const SearchBarMockup = () => (
<div className="flex w-full items-center gap-2 rounded-xl border bg-background px-3 py-2.5 shadow-sm">
<MapPin className="size-4 shrink-0 text-[#4285F4]" />
<span className="text-sm text-muted-foreground">Search your business...</span>
</div>
);
const GaugeMockup = () => (
<div className="flex flex-col items-center gap-2">
<svg width="64" height="36" viewBox="0 0 64 36" fill="none">
<path
d="M6 32 A26 26 0 0 1 58 32"
stroke="#e5e7eb"
strokeWidth="6"
strokeLinecap="round"
fill="none"
/>
<path
d="M6 32 A26 26 0 0 1 50 10"
stroke="#4285F4"
strokeWidth="6"
strokeLinecap="round"
fill="none"
/>
</svg>
<div className="flex w-full items-center justify-center gap-1.5">
{["#3b82f6", "#22c55e", "#f59e0b"].map((color) => (
<div
key={color}
className="h-1.5 flex-1 rounded-full"
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
);
const ReportMockup = () => (
<div className="flex w-full flex-col gap-2 rounded-lg border bg-background p-3 shadow-sm">
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
Reputation Blueprint
</span>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-md bg-[#4285F4] text-xs font-bold text-white">
83
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="h-1.5 w-full rounded-full bg-muted" />
<div className="h-1.5 w-4/5 rounded-full bg-muted" />
<div className="h-1.5 w-3/5 rounded-full bg-muted" />
</div>
</div>
</div>
);
const stepMockups = [SearchBarMockup, GaugeMockup, ReportMockup] as const;
const steps = [
{
key: "share",
icon: Icons.Link,
number: "1",
},
{
key: "analyze",
icon: Icons.Search,
number: "2",
},
{
key: "receive",
icon: Icons.FileText,
number: "3",
},
{ key: "share", number: 1 },
{ key: "analyze", number: 2 },
{ key: "receive", number: 3 },
] as const;
export const HowItWorks = async () => {
@@ -39,36 +84,42 @@ export const HowItWorks = async () => {
<SectionDescription>{t("howItWorks.description")}</SectionDescription>
</SectionHeader>
<div className="grid w-full max-w-5xl grid-cols-1 gap-8 md:grid-cols-3 md:gap-4 lg:gap-8">
{steps.map((step, index) => (
<div key={step.key} className="relative flex flex-col items-center text-center">
{index < steps.length - 1 && (
<div className="text-muted-foreground/30 absolute top-10 right-0 hidden translate-x-1/2 md:block">
<Icons.ChevronRight className="size-6" />
</div>
)}
<div className="relative grid w-full max-w-5xl grid-cols-1 gap-8 md:grid-cols-3 md:gap-6 lg:gap-10">
{steps.map((step, index) => {
const Mockup = stepMockups[index]!;
return (
<div
key={step.key}
className={cn(
"bg-primary/10 text-primary mb-4 flex size-20 items-center justify-center rounded-2xl",
"relative 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",
)}
>
<step.icon className="size-8" />
{/* 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">
{step.number}
</div>
</div>
{/* Visual mockup */}
<div className="flex w-full items-center justify-center px-2">
<Mockup />
</div>
{/* Text */}
<h3 className="text-lg font-semibold">
{t(`howItWorks.steps.${step.key}.title` as const)}
</h3>
<p className="text-sm text-muted-foreground text-balance">
{t(`howItWorks.steps.${step.key}.description` as const)}
</p>
</div>
<span className="text-muted-foreground mb-2 text-sm font-medium">
Step {step.number}
</span>
<h3 className="mb-2 text-lg font-semibold">
{t(`howItWorks.steps.${step.key}.title` as const)}
</h3>
<p className="text-muted-foreground text-sm text-balance">
{t(`howItWorks.steps.${step.key}.description` as const)}
</p>
</div>
))}
);
})}
</div>
</Section>
);

View File

@@ -0,0 +1,418 @@
"use client";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { CtaButton } from "~/modules/marketing/layout/cta-button";
import {
Section,
SectionBadge,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
// ---------------------------------------------------------------------------
// Demo data (hardcoded for "Bistro El Sol")
// ---------------------------------------------------------------------------
const SCORE = 83;
const SCORE_COLOR = "#22c55e";
const PILLARS = [
{ key: "ratingQuality", value: 88, color: "#4285F4" },
{ key: "sentimentDepth", value: 76, color: "#8b5cf6" },
{ key: "volume", value: 91, color: "#22c55e" },
{ key: "momentum", value: 72, color: "#f59e0b" },
{ key: "intensity", value: 85, color: "#3b82f6" },
] as const;
const DOMAINS = [
{ key: "operations", color: "#3b82f6", value: 78 },
{ key: "product", color: "#22c55e", value: 85 },
{ key: "journey", color: "#f59e0b", value: 62 },
{ key: "environment", color: "#8b5cf6", value: 71 },
{ key: "value", color: "#f43f5e", value: 68 },
{ key: "management", color: "#6b7280", value: 74 },
] as const;
const SEVERITY_COLORS: Record<string, string> = {
Critical: "#ef4444",
High: "#f97316",
Medium: "#f59e0b",
};
const EFFORT_COLORS: Record<string, string> = {
Low: "#22c55e",
Medium: "#f59e0b",
High: "#ef4444",
};
const IMPACT_COLORS: Record<string, string> = {
Low: "#6b7280",
Medium: "#f59e0b",
High: "#22c55e",
};
const DOMAIN_COLORS: Record<string, string> = {
Operations: "#3b82f6",
Product: "#22c55e",
Environment: "#8b5cf6",
};
const TABS = ["score", "domains", "issues", "actions"] as const;
type Tab = (typeof TABS)[number];
// ---------------------------------------------------------------------------
// Score Tab
// ---------------------------------------------------------------------------
function ScoreTab() {
const { t } = useTranslation("marketing");
const radius = 80;
const stroke = 12;
const cx = 100;
const cy = 100;
const circumference = Math.PI * radius;
const offset = circumference - (SCORE / 100) * circumference;
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">
<span className="text-muted-foreground text-xs tracking-wide uppercase">
{t("reportPreview.demo.businessName")}
</span>
<div className="relative" style={{ width: 200, height: 120 }}>
<svg
width="200"
height="120"
viewBox="0 0 200 120"
className="overflow-visible"
>
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
className="text-muted/20"
strokeWidth={stroke}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={0}
strokeLinecap="round"
transform={`rotate(180 ${cx} ${cy})`}
/>
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke={SCORE_COLOR}
strokeWidth={stroke}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(180 ${cx} ${cy})`}
className="transition-all duration-1000 ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span
className="text-6xl font-bold tabular-nums"
style={{ color: SCORE_COLOR }}
>
{SCORE}
</span>
<span className="text-muted-foreground text-xs tracking-wide">
{t("reportPreview.demo.scoreLabel")}
</span>
</div>
</div>
<div className="w-48">
<div className="flex h-2 overflow-hidden rounded-full">
<div style={{ width: "10%", backgroundColor: "#ef4444" }} />
<div style={{ width: "15%", backgroundColor: "#f97316" }} />
<div style={{ width: "15%", backgroundColor: "#f59e0b" }} />
<div style={{ width: "20%", backgroundColor: "#22c55e" }} />
<div style={{ width: "40%", backgroundColor: "#059669" }} />
</div>
<div className="relative h-2.5">
<div
className="absolute top-0"
style={{
left: `${SCORE}%`,
transform: "translateX(-50%)",
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderBottom: `6px solid ${SCORE_COLOR}`,
}}
/>
</div>
</div>
</div>
{/* Right: Pillar bars */}
<div className="flex w-full flex-1 flex-col gap-3">
{PILLARS.map((p) => (
<div key={p.key} className="flex items-center gap-3">
<span className="text-muted-foreground w-24 shrink-0 text-right text-xs sm:w-32 sm:text-sm">
{t(`reportPreview.demo.pillars.${p.key}` as const)}
</span>
<div className="bg-muted/30 relative h-2.5 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full transition-all duration-700 ease-out"
style={{
width: `${p.value}%`,
backgroundColor: p.color,
}}
/>
</div>
<span className="w-10 text-right text-sm font-semibold tabular-nums">
{p.value}%
</span>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Domains Tab
// ---------------------------------------------------------------------------
function DomainsTab() {
const { t } = useTranslation("marketing");
return (
<div className="flex flex-col gap-3.5">
{DOMAINS.map((d) => (
<div key={d.key} className="flex items-center gap-3">
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: d.color }}
/>
<span className="text-foreground w-32 shrink-0 text-sm">
{t(`reportPreview.demo.domains.${d.key}` as const)}
</span>
<div className="bg-muted/30 relative h-2.5 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full transition-all duration-700 ease-out"
style={{ width: `${d.value}%`, backgroundColor: d.color }}
/>
</div>
<span
className="w-9 text-right text-sm font-semibold tabular-nums"
style={{ color: d.color }}
>
{d.value}%
</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Issues Tab
// ---------------------------------------------------------------------------
function IssuesTab() {
const { t } = useTranslation("marketing");
const issues = [
{ key: "issue1" as const, idx: 0 },
{ key: "issue2" as const, idx: 1 },
{ key: "issue3" as const, idx: 2 },
];
return (
<div className="flex flex-col gap-4">
{issues.map(({ key: issueKey, idx }) => {
const severity = t(`reportPreview.demo.issues.${issueKey}.severity` as const);
const severityColor = SEVERITY_COLORS[severity] ?? "#f59e0b";
const domain = t(`reportPreview.demo.issues.${issueKey}.domain` as const);
const domainColor = DOMAIN_COLORS[domain] ?? "#6b7280";
return (
<div
key={issueKey}
className="bg-muted/20 flex gap-3 rounded-lg border p-3"
>
<div
className="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: severityColor }}
>
{idx + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-semibold leading-tight">
{t(`reportPreview.demo.issues.${issueKey}.title` as const)}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: domainColor }}
/>
<span className="text-muted-foreground">{domain}</span>
</span>
<span className="text-muted-foreground text-xs">
{t(`reportPreview.demo.issues.${issueKey}.complaints` as const)}
</span>
<span
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ backgroundColor: severityColor }}
>
{severity}
</span>
</div>
<div
className="mt-2 border-l-2 pl-3"
style={{ borderColor: severityColor }}
>
<p className="text-muted-foreground text-xs italic leading-relaxed">
{t(`reportPreview.demo.issues.${issueKey}.quote` as const)}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Actions Tab
// ---------------------------------------------------------------------------
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 },
];
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);
return (
<div
key={actionKey}
className="bg-muted/20 flex gap-3 rounded-lg border 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="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>
</div>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const ReportPreview = () => {
const [activeTab, setActiveTab] = useState<Tab>("score");
const { t } = useTranslation("marketing");
return (
<Section id="report-preview">
<SectionHeader>
<SectionBadge>{t("reportPreview.label")}</SectionBadge>
<SectionTitle>{t("reportPreview.title")}</SectionTitle>
<SectionDescription>
{t("reportPreview.description")}
</SectionDescription>
</SectionHeader>
<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="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>
{/* Card */}
<div className="bg-background relative overflow-hidden rounded-2xl border shadow-sm">
{/* Tab bar */}
<div className="flex border-b">
{TABS.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"text-muted-foreground hover:text-foreground relative flex-1 px-4 py-3 text-sm font-medium transition-colors",
activeTab === tab && "text-foreground",
)}
>
{t(`reportPreview.tabs.${tab}` as const)}
{activeTab === tab && (
<span
className="absolute inset-x-0 bottom-0 h-0.5"
style={{ backgroundColor: "#4285F4" }}
/>
)}
</button>
))}
</div>
{/* Tab content */}
<div className="p-6 sm:p-8">
{activeTab === "score" && <ScoreTab />}
{activeTab === "domains" && <DomainsTab />}
{activeTab === "issues" && <IssuesTab />}
{activeTab === "actions" && <ActionsTab />}
</div>
</div>
</div>
<CtaButton>{t("reportPreview.cta")}</CtaButton>
</Section>
);
};

View File

@@ -0,0 +1,206 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { CtaButton } from "~/modules/marketing/layout/cta-button";
import {
Section,
SectionBadge,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
// ---------------------------------------------------------------------------
// Valence & domain color maps (from report engine)
// ---------------------------------------------------------------------------
const VALENCE = {
"+": { bg: "#DCFCE7", text: "#166534", border: "#22c55e" },
"-": { bg: "#FEE2E2", text: "#991B1B", border: "#ef4444" },
"±": { bg: "#FEF3C7", text: "#92400E", border: "#f59e0b" },
"0": { bg: "#F3F4F6", text: "#6B7280", border: "#9ca3af" },
} as const;
const DOMAIN_COLORS: Record<string, string> = {
Operations: "#3b82f6",
Product: "#22c55e",
Journey: "#f59e0b",
Environment: "#8b5cf6",
Value: "#f43f5e",
Management: "#6b7280",
};
// ---------------------------------------------------------------------------
// Demo review data — hardcoded annotated reviews for "Bistro El Sol"
// ---------------------------------------------------------------------------
type Valence = "+" | "-" | "±" | "0";
type Segment =
| { type: "text"; content: string }
| { type: "hl"; content: string; primitive: string; domain: string; valence: Valence };
interface DemoReview {
author: string;
rating: number;
date: string;
segments: Segment[];
}
const REVIEWS: DemoReview[] = [
{
author: "Elena R.",
rating: 5,
date: "Jan 2025",
segments: [
{ type: "text", content: "The " },
{ type: "hl", content: "paella was absolutely incredible", primitive: "Food Quality", domain: "Product", valence: "+" },
{ type: "text", content: " — " },
{ type: "hl", content: "perfectly seasoned", primitive: "Taste", domain: "Product", valence: "+" },
{ type: "text", content: " and " },
{ type: "hl", content: "generous portions", primitive: "Portion Size", domain: "Value", valence: "+" },
{ type: "text", content: ". Our waiter " },
{ type: "hl", content: "Miguel was attentive", primitive: "Staff Attentiveness", domain: "Operations", valence: "+" },
{ type: "text", content: " and " },
{ type: "hl", content: "recommended the perfect wine pairing", primitive: "Recommendations", domain: "Operations", valence: "+" },
{ type: "text", content: ". Only downside was the " },
{ type: "hl", content: "25-minute wait for a table", primitive: "Wait Time", domain: "Journey", valence: "-" },
{ type: "text", content: " on a Friday night." },
],
},
{
author: "James K.",
rating: 3,
date: "Dec 2024",
segments: [
{ type: "hl", content: "Food quality is usually great", primitive: "Consistency", domain: "Product", valence: "±" },
{ type: "text", content: " but tonight the " },
{ type: "hl", content: "steak was overcooked", primitive: "Food Quality", domain: "Product", valence: "-" },
{ type: "text", content: " and the " },
{ type: "hl", content: "fries were cold", primitive: "Temperature", domain: "Product", valence: "-" },
{ type: "text", content: ". The " },
{ type: "hl", content: "terrace ambiance is lovely", primitive: "Ambiance", domain: "Environment", valence: "+" },
{ type: "text", content: " though, and " },
{ type: "hl", content: "prices are fair for the area", primitive: "Pricing", domain: "Value", valence: "+" },
{ type: "text", content: ". Would come back but hope for " },
{ type: "hl", content: "more consistency", primitive: "Consistency", domain: "Management", valence: "-" },
{ type: "text", content: "." },
],
},
{
author: "Patricia M.",
rating: 4,
date: "Nov 2024",
segments: [
{ type: "text", content: "We celebrated our anniversary here. The " },
{ type: "hl", content: "decoration is beautiful", primitive: "Decor", domain: "Environment", valence: "+" },
{ type: "text", content: " and the " },
{ type: "hl", content: "staff made us feel special", primitive: "Hospitality", domain: "Operations", valence: "+" },
{ type: "text", content: " with a complimentary dessert. " },
{ type: "hl", content: "Appetizers were outstanding", primitive: "Food Quality", domain: "Product", valence: "+" },
{ type: "text", content: " but the " },
{ type: "hl", content: "main course took 35 minutes", primitive: "Wait Time", domain: "Journey", valence: "-" },
{ type: "text", content: ". The " },
{ type: "hl", content: "bill was a bit steep", primitive: "Pricing", domain: "Value", valence: "-" },
{ type: "text", content: " for what we got. Still, a " },
{ type: "hl", content: "lovely evening overall", primitive: "Overall Experience", domain: "Management", valence: "+" },
{ type: "text", content: "." },
],
},
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const ReviewEvidence = () => {
const { t } = useTranslation("marketing");
return (
<Section id="review-evidence">
<SectionHeader>
<SectionBadge>{t("reviewEvidence.label")}</SectionBadge>
<SectionTitle>{t("reviewEvidence.title")}</SectionTitle>
<SectionDescription>
{t("reviewEvidence.description")}
</SectionDescription>
</SectionHeader>
<div className="w-full max-w-3xl space-y-4">
{REVIEWS.map((review) => (
<div
key={review.author}
className="bg-background rounded-xl border p-5 shadow-sm sm:p-6"
>
{/* Header — author · stars · date */}
<div className="mb-3 flex items-center gap-2.5">
<span className="text-foreground text-sm font-semibold">
{review.author}
</span>
<span className="text-xs tracking-wider" aria-label={`${review.rating} out of 5 stars`}>
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
style={{ color: i < review.rating ? "#FBBC05" : "#D1D5DB" }}
>
</span>
))}
</span>
<span className="text-muted-foreground text-xs">
{review.date}
</span>
</div>
{/* Highlighted review text */}
<p className="text-[15px] leading-[1.75]">
{review.segments.map((seg, i) =>
seg.type === "text" ? (
<span key={i} className="text-muted-foreground">
{seg.content}
</span>
) : (
<span
key={i}
title={`${seg.primitive} (${seg.valence})`}
className="inline rounded-sm px-0.5 font-medium"
style={{
backgroundColor: VALENCE[seg.valence].bg,
borderBottom: `2px solid ${VALENCE[seg.valence].border}`,
color: VALENCE[seg.valence].text,
}}
>
{seg.content}
</span>
),
)}
</p>
{/* Classification tags */}
<div className="mt-3 flex flex-wrap gap-1.5">
{review.segments
.filter((s): s is Extract<Segment, { type: "hl" }> => s.type === "hl")
.map((seg, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-[10px] font-medium sm:text-[11px]"
style={{
backgroundColor: VALENCE[seg.valence].bg,
color: VALENCE[seg.valence].text,
borderLeft: `3px solid ${DOMAIN_COLORS[seg.domain] ?? "#6b7280"}`,
}}
>
{seg.primitive}
<span className="font-bold">{seg.valence}</span>
</span>
))}
</div>
</div>
))}
</div>
<CtaButton>{t("reviewEvidence.cta")}</CtaButton>
</Section>
);
};

View File

@@ -0,0 +1,33 @@
import { getTranslation } from "@turbostarter/i18n/server";
const stats = [
{ key: "reports" },
{ key: "rating" },
{ key: "dimensions" },
] as const;
export const SocialProof = async () => {
const { t } = await getTranslation({ ns: "marketing" });
return (
<div className="border-y bg-muted/30 py-6 sm:py-8">
<div className="mx-auto flex max-w-5xl flex-col items-center justify-center gap-6 px-6 sm:flex-row sm:gap-12 md:gap-16">
{stats.map((stat) => {
const text = t(`socialProof.${stat.key}` as const);
const match = text.match(/^([\d.+]+)\s+(.+)$/);
const number = match?.[1] ?? text;
const label = match?.[2] ?? "";
return (
<div key={stat.key} className="flex flex-col items-center text-center">
<span className="text-2xl font-bold tracking-tight sm:text-3xl">
{number}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -25,6 +25,7 @@ const reviews = [
body: "testimonials.reviews.maria.body",
img: "https://avatar.vercel.sh/maria",
position: "testimonials.reviews.maria.position",
improvement: "4.1 → 4.6",
},
{
name: "testimonials.reviews.carlos.name",
@@ -52,7 +53,7 @@ const reviews = [
},
] as const;
type Review = (typeof reviews)[number];
type Review = (typeof reviews)[number] & { improvement?: string };
export const Testimonials = () => {
const { t } = useTranslation("marketing");
@@ -109,7 +110,7 @@ export const Testimonials = () => {
);
};
const ReviewCard = ({ img, name, position, body }: Review) => {
const ReviewCard = ({ img, name, position, body, improvement }: Review) => {
const { t } = useTranslation("marketing");
return (
<figure
@@ -130,6 +131,17 @@ const ReviewCard = ({ img, name, position, body }: Review) => {
{t(position)}
</p>
</div>
{improvement && (
<span className="ml-auto inline-flex items-center gap-1 rounded-full border border-green-200 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
<Icons.TrendingUp className="size-3" />
{improvement}
</span>
)}
</div>
<div className="mt-1.5 flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Icons.Star key={i} className="size-3.5 fill-yellow-500 text-yellow-500" />
))}
</div>
<blockquote className="mt-2 text-sm">{t(body)}</blockquote>
</figure>