Files
zeekay ac64d5d7dd Folds say one line: every first screen keeps its heading and a single lede
The owner's spec for a landing page is the models bento's: monochrome, the
words left and the art right, no extra prose. The folds carried two to four
sentences under each heading, and some a claim, a lede and a list besides.
Each now says one line: ProductLanding the tagline where a page states one
(the lede still describes it to search), a claim alone where the claim is
the line (/payments, /treasury, /zap, the extension), and elsewhere the
sentence that says what the thing is. /philosophy's two points, /dns' claim
that repeated its title, /calculator's capabilities line and the
Spare clauses on /research and /learn go.
2026-10-30 06:42:40 -07:00

315 lines
12 KiB
TypeScript

'use client'
/**
* /automations — every app a Hanzo Auto workflow can reach, searchable.
*
* NO TAILWIND. A filter chip's resting colour and its hover are ONE decision,
* so both are props on one `Cell` — the ink and its `hoverStyle` side by side,
* where neither can out-rank the other.
*
* Everything is gui props and @hanzo/ui's marketing components — `Display`,
* `Lede`, `Eyebrow`, `Title`, `Chip`, `Action`, `Line` for the small type, and
* `Leaf` for the cards, which is where the `group`/`group-hover:` pairs went: a
* card decides the hover and `Lift` decides who takes part.
*/
import React, { useState } from "react";
import { motion } from "@/components/motion";
import {
ArrowRight,
Search,
Zap,
MessageSquare,
Database,
Users,
ShoppingCart,
Mail,
FileText,
Code,
Bot,
Share2,
ExternalLink
} from "lucide-react";
import { phrase } from "@hanzo/i18n";
import { useI18n } from "@hanzo/i18n/react";
import { Box, View, XStack, YStack } from '@hanzo/ui'
import { Input } from '@hanzo/gui'
import { Band } from '@/components/marketing/band'
/* The catalogue is DATA and lives with the other catalogues, in lib/data.
* Eighty-one vendor brand colours were declared here as literals; a brand
* hex is not a theme value and no token can hold it, so the answer is not to
* name them but to stop keeping them in a component. `lib/data` is where
* connectors, papers, pricing and models already live. */
import integrations from '@/lib/data/integrations.json'
import { Action, Chip, Eyebrow, Lede, Line, Title, Cell, Leaf } from '@hanzo/ui/marketing'
import { Fold } from '@/components/marketing/fold'
import { bare } from '@hanzo/app/lib/bare'
// Integration categories
const categories = [
{ id: "all", name: phrase("All"), icon: Zap },
{ id: "ai", name: phrase("AI & LLMs"), icon: Bot },
{ id: "communication", name: phrase("Communication"), icon: MessageSquare },
{ id: "productivity", name: phrase("Productivity"), icon: FileText },
{ id: "data", name: phrase("Data & Storage"), icon: Database },
{ id: "crm", name: phrase("CRM & Sales"), icon: Users },
{ id: "commerce", name: phrase("Commerce"), icon: ShoppingCart },
{ id: "marketing", name: phrase("Marketing"), icon: Mail },
{ id: "developer", name: phrase("Developer Tools"), icon: Code },
{ id: "social", name: phrase("Social Media"), icon: Share2 },
];
/**
* A filter chip: a `Cell`'s ground and ink, at rest and on approach. Both
* grounds are one colour — `--secondary` and `--accent` are the same value in
* both themes — so what actually answers a pointer here is the ink, which is
* worth knowing before anyone "fixes" the pair.
*/
const CHIP = {
display: 'inline-flex',
items: 'center',
gap: 8,
px: 16,
py: 8,
rounded: 'var(--radius-full)',
fontSize: '$2',
fontWeight: '500',
whiteSpace: 'nowrap',
bg: 'var(--secondary)',
color: 'var(--muted-foreground)',
hoverStyle: { bg: 'var(--accent)', color: 'var(--foreground)' },
} as const
/** The chip that is chosen, which does not answer a pointer: it is already the
* one. `bg-primary` compiles to `--pure-white` here. */
const SELECTED = {
...CHIP,
bg: 'var(--pure-white)',
color: 'var(--primary-foreground)',
hoverStyle: { bg: 'var(--pure-white)', color: 'var(--primary-foreground)' },
} as const
/** A card in the grid: `Leaf`'s three properties, plus its ground. */
const CARD = { p: 16, borderColor: "var(--border)", bg: "color-mix(in oklab, var(--secondary) 50%, transparent)", cursor: "pointer" } as const
/** A name in the "most reached for" row, worn as `Chip`. */
const REACHED = { px: 16, py: 8, bg: "var(--secondary)" } as const
const Automations = () => {
const { t } = useI18n();
const [activeCategory, setActiveCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const filteredIntegrations = integrations.filter((integration) => {
const matchesCategory = activeCategory === "all" || integration.category === activeCategory;
const matchesSearch = integration.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
const popularIntegrations = integrations.filter((i) => i.popular);
return (
<Box minH="100vh" $platform-web={{ color: 'var(--foreground)' }}>
<main>
<Fold
label="Hanzo Auto · Connectors"
title="The apps a workflow can reach"
lede="Every one of these is a step you can drop onto a Hanzo Auto canvas: read from it, write to it, or let a change in it start a run."
>
<YStack width="100%" gap={40}>
{/* Search */}
<Box position="relative" width="100%" maxW={448} mx="auto">
<View position="absolute" l={16} t="50%" y="-50%">
<Search width={20} height={20} color="var(--muted-foreground)" />
</View>
{/* gui's Input: a placeholder is a pseudo-element, and
`placeholderTextColor` is the prop that reaches it.
`unstyled` zeroes the outline, so the focus ring gets its
width back. */}
<Input
unstyled
type="text"
placeholder={t('Search integrations...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
width="100%"
pl={48}
pr={16}
py={12}
rounded="var(--radius-full)"
bg="var(--secondary)"
borderWidth={1}
borderColor="var(--border)"
fontFamily="$body"
fontSize="$3"
lineHeight="$3"
color="var(--foreground)"
placeholderTextColor="$placeholderColor"
focusVisibleStyle={{ outlineWidth: 2 }}
/>
</Box>
{/* Popular Integrations */}
<Box render={<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: 0.1 }} />}>
<Eyebrow render="h2" mb={16} text="center">
{t('Most reached for')}
</Eyebrow>
<Box display="flex" flexDirection="row" flexWrap="wrap" justify="center" gap={12}>
{popularIntegrations.slice(0, 12).map((integration) => (
<Chip
render={<motion.a whileHover={{ scale: 1.05 }} />}
key={integration.name}
href={`#${integration.name.toLowerCase().replace(/\s+/g, "-")}`}
{...REACHED}
>
<Line fontSize="$4" color="inherit">{integration.icon}</Line>
<span>{integration.name}</span>
</Chip>
))}
</Box>
</Box>
</YStack>
</Fold>
{/* Category Filter */}
<Box
render="section"
position="sticky"
t={64}
z="var(--z-drawer)"
py={32}
px={16}
$md={{ px: 32 }}
borderTopWidth={1}
borderBottomWidth={1}
borderColor="var(--border)"
bg="var(--background)"
>
<Box width="100%" maxW={1152} mx="auto">
<XStack gap={8} overflowX="auto" pb={8} {...bare}>
{categories.map((category) => {
const Icon = category.icon;
const isActive = activeCategory === category.id;
return (
<Cell
render="button"
key={category.id}
onClick={() => setActiveCategory(category.id)}
{...(isActive ? SELECTED : CHIP)}
>
<Icon width={16} height={16} />
{t(category.name)}
</Cell>
);
})}
</XStack>
</Box>
</Box>
{/* Integrations Grid */}
<Band rule={false} pad="$10" measure={1152}>
<Box display="flex" flexDirection="row" items="center" justify="space-between" mb={32}>
<Line render="h2" display="block" fontSize="$7" lineHeight="var(--leading-snug)" fontWeight="500" color="var(--foreground)">
{activeCategory === "all"
? t("All Integrations")
: t(categories.find(c => c.id === activeCategory)?.name ?? "")}
</Line>
<Line fontSize="$2" color="var(--muted-foreground)">
{t('{n} integrations', { n: filteredIntegrations.length })}
</Line>
</Box>
<Box
display="grid"
gridTemplateColumns="repeat(2, minmax(0, 1fr))"
gap={16}
$sm={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}
$md={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}
$lg={{ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' }}
>
{filteredIntegrations.map((integration, index) => (
<Leaf
render={<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: index * 0.02 }} />}
key={integration.name}
{...CARD}
>
{/* The ground is the vendor's own hex at alpha 0x20, one per row of data. */}
<Line
render="div"
display="flex"
flexDirection="row"
items="center"
justify="center"
width={48}
height={48}
mb={12}
rounded="var(--radius-xl)"
bg={integration.color + "20"}
fontSize="$7"
color="inherit"
>
{integration.icon}
</Line>
{/* Stated, not inherited: base.css inks every heading
`--text-primary`, which an inherited value cannot beat. It
does not lift: the card's edge is the whole treatment. */}
<Line render="h3" display="block" fontSize="$2" fontWeight="500" color="var(--foreground)">
{integration.name}
</Line>
{integration.popular && (
<Line fontSize="var(--text-floor)" fontWeight="500" color="inherit">
{t('Popular')}
</Line>
)}
</Leaf>
))}
</Box>
{filteredIntegrations.length === 0 && (
<Box py={64} $platform-web={{ textAlign: 'center' }}>
<Line render="p" display="block" color="var(--muted-foreground)">
{t('No integrations found matching your search.')}
</Line>
</Box>
)}
</Band>
{/* CTA Section */}
<Band pad="$11" measure={896}>
<Box $platform-web={{ textAlign: 'center' }}>
<Title quiet mb={16}>
{t('Not here?')}
</Title>
<Lede mb={32} mt="$3.5">
{t('Anything with an HTTP API can be a step today — a request node and a connection holding the credential. If it deserves a proper connector, tell us and we will add one.')}
</Lede>
<YStack items="center" justify="center" gap={16} $sm={{ flexDirection: 'row' }}>
<Action
href="/contact"
fill
>
{t('Request Integration')}
<ArrowRight width={16} height={16} data-i18n-mirror />
</Action>
<Action
href="https://docs.hanzo.ai/docs/cli/auto"
target="_blank"
rel="noreferrer noopener"
>
{t('Build Custom Integration')}
<ExternalLink width={16} height={16} />
</Action>
</YStack>
</Box>
</Band>
</main>
</Box>
);
};
export default Automations;