Files
zeekayandHanzo Dev f11062f432 fix(analytics): theme-aware hero ground and surfaces to fix light-mode contrast
The /analytics landing page hardcoded theme-independent colors
(var(--pure-black), var(--pure-white), --neutral-*) into the hero ground,
dashboard mock, code windows and section washes. In light mode that meant
near-black ink on a pure-black hero and white chart lines on light
surfaces — unreadable and harsh.

Swap them for @hanzo/design's theme-aware tokens (--background, --muted,
--secondary, --primary, --muted-foreground). Dark rendering is unchanged;
light mode now resolves correctly.

Verified with headless Chromium (hero bg/ink before vs after) and clean
tsc --noEmit / design lint.


Co-authored-by: Hanzo Dev <[email protected]>
2026-10-25 09:02:26 -07:00

253 lines
9.2 KiB
TypeScript

'use client'
import React, { useEffect, useRef, useState } from "react";
import { motion } from '@/components/motion';
import { useAnimation, type Variants } from 'framer-motion';
import { LineChart, Users, Filter, Layers, Activity } from "lucide-react";
import { Band, Head, INK } from '@/components/marketing/band'
import { Body, Tile, Title, ink, tint } from './kit'
import { Box, Pulse, Text, XStack, YStack } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
const features = [
{
icon: <Activity />,
title: phrase("Realtime"),
description: phrase("Who is on the site right now, which page they are on and how they got there. Useful the hour you ship something and the hour something breaks.")
},
{
icon: <Users />,
title: phrase("Journeys"),
description: phrase("The route people actually took through the site, laid out as a tree. It rarely matches the one in the navigation, and that gap is the finding.")
},
{
icon: <Layers />,
title: phrase("Funnels, goals and retention"),
description: phrase("Define the steps and see where the drop is. Goals count the thing you care about, and retention answers the harder question of whether anyone came back.")
},
{
icon: <LineChart />,
title: phrase("Events"),
description: phrase("Track a click or a signup with a name and a few properties. No schema to declare up front and nothing to migrate when you add one.")
},
{
icon: <Filter />,
title: phrase("Attribution and UTM"),
description: phrase("Which channel, campaign and referrer brought the session that converted — including the touch that started it, not only the one that closed it.")
}
];
const WebAnalytics = () => {
const { t } = useI18n();
const [animationTriggered, setAnimationTriggered] = useState(false);
const chartRef = useRef<HTMLDivElement>(null);
const controls = useAnimation();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !animationTriggered) {
controls.start("animate");
setAnimationTriggered(true);
}
},
{ threshold: 0.3 }
);
if (chartRef.current) {
observer.observe(chartRef.current);
}
return () => {
if (chartRef.current) {
observer.unobserve(chartRef.current);
}
};
}, [controls, animationTriggered]);
const lineVariants: Variants = {
initial: {
pathLength: 0
},
animate: {
pathLength: 1,
transition: {
duration: 2,
ease: "easeInOut"
}
}
};
const dataPointVariants = {
initial: {
opacity: 0,
scale: 0
},
animate: (i: number) => ({
opacity: 1,
scale: 1,
transition: {
delay: 1.5 + i * 0.1,
duration: 0.3
}
})
};
return (
<Band rule={false} pad="$14" measure={1280} ground={INK}>
<Box position="relative" z="var(--z-raised)">
<Head
loud
title={t('The reports')}
lede={t('Each one answers a question you would otherwise export a CSV to answer.')}
/>
<Grid columns={{ min: 420, max: 2 }} gap={64} mb={80}>
<YStack gap="$6">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<XStack>
<Box mr="$4" mt="$1" p="$2" rounded="var(--radius-lg)" bg="$raised" $platform-web={{ color: 'var(--foreground)' }}>
{feature.icon}
</Box>
<Box>
<Title mb={8} color="var(--foreground)">
{t(feature.title)}
</Title>
<Body>{t(feature.description)}</Body>
</Box>
</XStack>
</motion.div>
))}
</YStack>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
ref={chartRef}
>
<Tile p="$5" position="relative">
<XStack mb="$4" justify="space-between" items="center">
<Text render="h3" display="block" fontSize="$4" lineHeight="1.4rem" fontWeight="500" color="inherit">
{t('Live User Activity')}
</Text>
<Pulse render="span" height={8} width={8} rounded={9999} $platform-web={{ backgroundColor: tint(10) }} />
</XStack>
<Box position="relative" height={320} width="100%">
{/* Chart grid */}
<Box position="absolute" t={0} r={0} b={0} l={0} display="grid" gridTemplateColumns="repeat(6, 1fr)" gridTemplateRows="repeat(5, 1fr)">
{Array(30).fill(0).map((_, i) => (
<Box key={i} borderBottomWidth={1} borderRightWidth={1} borderColor="var(--neutral-800)" opacity={0.3} />
))}
</Box>
{/* Chart area */}
<Box position="absolute" t={0} r={0} b={0} l={0} p="$4">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<motion.path
d="M0,80 C10,70 20,85 30,60 C40,40 50,55 60,30 C70,40 80,20 90,10 L100,20"
stroke="var(--primary)"
strokeWidth="2"
fill="none"
variants={lineVariants}
initial="initial"
animate={controls}
/>
<motion.path
d="M0,90 C15,80 25,75 35,70 C45,65 55,60 65,50 C75,70 85,60 95,40 L100,50"
stroke="var(--muted-foreground)"
strokeWidth="2"
fill="none"
variants={lineVariants}
initial="initial"
animate={controls}
/>
{[
{ x: 30, y: 60, color: "var(--foreground)" },
{ x: 60, y: 30, color: "var(--foreground)" },
{ x: 90, y: 10, color: "var(--foreground)" },
{ x: 35, y: 70, color: "var(--muted-foreground)" },
{ x: 65, y: 50, color: "var(--muted-foreground)" },
{ x: 95, y: 40, color: "var(--muted-foreground)" }
].map((point, i) => (
<motion.circle
key={i}
cx={point.x}
cy={point.y}
r="2"
fill={point.color}
custom={i}
variants={dataPointVariants}
initial="initial"
animate={controls}
/>
))}
</svg>
</Box>
{/* Tooltip */}
<Box position="absolute" t="30%" l="60%">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={controls}
variants={{
animate: {
opacity: 1,
y: 0,
transition: { delay: 2, duration: 0.3 }
}
}}
>
<Box width={128} p={8} rounded={4} borderWidth={1} borderColor="var(--neutral-700)" bg="var(--secondary)" boxShadow="var(--shadow-lg)">
<XStack justify="space-between">
<Text {...TIP}>{t('Pageviews')}</Text>
<Text {...TIP} fontWeight="700">
1,240
</Text>
</XStack>
<XStack mt="$1" justify="space-between">
<Text {...TIP}>{t('Users')}</Text>
<Text {...TIP} fontWeight="700">
876
</Text>
</XStack>
<Text display="block" mt={4} text="right" {...TIP} color={undefined} $platform-web={{ color: ink(70) }}>
+24.5%
</Text>
</Box>
</motion.div>
</Box>
</Box>
<XStack justify="space-between" mt={8}>
{['00:00', '06:00', '12:00', '18:00', '24:00'].map((hour) => (
<Text key={hour} fontSize="$2" lineHeight="1.15rem" color="var(--muted-foreground)">
{hour}
</Text>
))}
</XStack>
</Tile>
</motion.div>
</Grid>
</Box>
</Band>
);
};
/** The tooltip's type: the smallest rung, at its leading. */
const TIP = { fontSize: '$1', lineHeight: '1rem', color: 'inherit' } as const
export default WebAnalytics;