mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
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]>
253 lines
9.2 KiB
TypeScript
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;
|