mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
315 lines
12 KiB
TypeScript
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;
|