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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
418
apps/web/src/modules/marketing/home/report-preview.tsx
Normal file
418
apps/web/src/modules/marketing/home/report-preview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
206
apps/web/src/modules/marketing/home/review-evidence.tsx
Normal file
206
apps/web/src/modules/marketing/home/review-evidence.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
apps/web/src/modules/marketing/home/social-proof.tsx
Normal file
33
apps/web/src/modules/marketing/home/social-proof.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user