mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
483 lines
21 KiB
TypeScript
483 lines
21 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { ArrowUpRight, Check, Clock, Loader2 } from 'lucide-react'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
|
||
import { WORKSPACE } from '@hanzo/app/lib/host'
|
||
import { Section, node, useGrow, useSeen, useStill } from './chrome'
|
||
|
||
/**
|
||
* The products: modern visual preview panels matching the Hanzo ecosystem
|
||
* arranged in a clean, balanced responsive grid (3 on top, 2 on bottom).
|
||
*
|
||
* Each scene is authored sample copy, so it is drawn in the reader's language
|
||
* (`phrase` marks the lines written as data); a product's name, a person's name,
|
||
* a channel, a file path and the diff's code stay as written, and the diff reads
|
||
* left to right on every page, as code does.
|
||
*/
|
||
const PRODUCTS = [
|
||
{
|
||
key: 'chat',
|
||
name: 'Chat',
|
||
tag: 'Hanzo Chat',
|
||
href: '/chat',
|
||
top: true,
|
||
},
|
||
{
|
||
key: 'dev',
|
||
name: 'Dev',
|
||
tag: 'Hanzo Dev',
|
||
href: '/dev',
|
||
top: true,
|
||
},
|
||
{
|
||
key: 'bot',
|
||
name: 'Bot',
|
||
tag: 'Hanzo Bot',
|
||
href: 'https://hanzo.bot',
|
||
top: true,
|
||
},
|
||
{
|
||
key: 'studio',
|
||
name: 'Studio',
|
||
tag: 'Hanzo Studio',
|
||
href: '/studio',
|
||
top: false,
|
||
},
|
||
{
|
||
key: 'team',
|
||
name: 'Team',
|
||
tag: 'Hanzo Team',
|
||
href: WORKSPACE,
|
||
top: false,
|
||
},
|
||
] as const
|
||
|
||
/**
|
||
* A card's scene: step 0 to `beats.length`, each `beats[i]` milliseconds after
|
||
* the last, then held for `rest` and played again. It starts when the card is
|
||
* first seen; before that, and under reduced motion, it stands at its last
|
||
* step, whole.
|
||
*/
|
||
function useScene(beats: readonly number[], play: boolean, still: boolean, rest = 2800) {
|
||
const end = beats.length
|
||
const [at, setAt] = useState(end)
|
||
useEffect(() => {
|
||
if (still || !play) return void setAt(end)
|
||
let k = 0
|
||
let t = 0
|
||
setAt(0)
|
||
const next = () => {
|
||
k = k >= end ? 0 : k + 1
|
||
setAt(k)
|
||
t = window.setTimeout(next, k >= end ? rest : beats[k] ?? 600)
|
||
}
|
||
t = window.setTimeout(next, beats[0] ?? 600)
|
||
return () => window.clearTimeout(t)
|
||
}, [play, still, end, rest, beats])
|
||
return at
|
||
}
|
||
|
||
/** Three dots that pulse in turn: someone is typing (`[data-typing]` in app/layout.tsx). */
|
||
function Typing() {
|
||
return (
|
||
<XStack data-typing="" aria-hidden gap={4} px={14} py={12} rounded={18} bg="var(--white-05)" self="flex-start" $platform-web={{ borderRadius: '18px' }}>
|
||
{[0, 1, 2].map((d) => (
|
||
<View key={d} width={6} height={6} rounded={9999} bg="var(--muted-foreground)" $platform-web={{ borderRadius: '9999px' }} />
|
||
))}
|
||
</XStack>
|
||
)
|
||
}
|
||
|
||
/** One chat bubble: the person's at the end of the line, the answer at its start. */
|
||
function Bubble({ mine, children }: { mine?: boolean; children: string }) {
|
||
return (
|
||
<XStack data-motion="rise" justify={mine ? 'flex-end' : 'flex-start'}>
|
||
<View px={14} py={mine ? 8 : 10} rounded={18} bg={mine ? 'var(--secondary)' : 'var(--white-05)'} maxW={mine ? '88%' : '92%'} $platform-web={{ borderRadius: '18px' }}>
|
||
<Text fontSize="$2" lineHeight="$3" color={mine ? 'var(--foreground)' : 'var(--muted-foreground)'}>
|
||
{children}
|
||
</Text>
|
||
</View>
|
||
</XStack>
|
||
)
|
||
}
|
||
|
||
const CHAT = [
|
||
{ mine: true, text: phrase('How do black holes form?') },
|
||
{ mine: false, text: phrase('A massive star runs out of fuel and gravity collapses its core into a singularity.') },
|
||
{ mine: true, text: phrase('What causes the aurora?') },
|
||
{ mine: false, text: phrase('Solar particles hit gases high over the poles and make them glow.') },
|
||
] as const
|
||
/** Each message, an answer after a moment of typing. */
|
||
const CHAT_BEATS = [500, 900, 1200, 1500, 900, 1200] as const
|
||
|
||
function ChatPreview({ play, still }: { play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const at = useScene(CHAT_BEATS, play, still)
|
||
// Steps: 1 ask, 2 typing, 3 answer, 4 ask, 5 typing, 6 answer.
|
||
const shown = [at >= 1, at >= 3, at >= 4, at >= 6]
|
||
const typing = at === 2 || at === 5
|
||
return (
|
||
<YStack p={16} gap={10} flex={1} justify="flex-end" overflow="hidden" minH={300}>
|
||
{CHAT.map((m, k) => (shown[k] ? <Bubble key={k} mine={m.mine}>{t(m.text)}</Bubble> : null))}
|
||
{typing ? <Typing /> : null}
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
const DIFF = [
|
||
{ n: 42, kind: 'same', text: 'export async function handler(req) {' },
|
||
{ n: 43, kind: 'add', text: ' const token = extractBearer(req);' },
|
||
{ n: 44, kind: 'add', text: ' if (!token) return unauthorized();' },
|
||
{ n: 47, kind: 'cut', text: ' const session = await getSession(req);' },
|
||
{ n: 48, kind: 'add', text: ' req.user = await verify(token);' },
|
||
{ n: 49, kind: 'add', text: ' return next(req);' },
|
||
] as const
|
||
/** Thinking, the file opened, then each line of the diff. */
|
||
const DEV_BEATS = [700, 900, 500, 450, 450, 450, 450, 450, 450, 900] as const
|
||
|
||
function DevPreview({ play, still }: { play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const at = useScene(DEV_BEATS, play, still)
|
||
const lines = Math.max(0, Math.min(DIFF.length, at - 2))
|
||
const done = at >= DEV_BEATS.length
|
||
return (
|
||
<YStack p={16} gap={8} flex={1} justify="center" overflow="hidden" minH={300}>
|
||
<View rounded={12} bg="var(--surface-card-quiet, var(--card))" borderWidth={1} borderColor="var(--border)" overflow="hidden" $platform-web={{ borderRadius: '12px' }}>
|
||
<XStack px={10} py={6} bg="var(--white-03)" items="center" justify="space-between" borderBottomWidth={1} borderColor="var(--border)">
|
||
<XStack items="center" gap={6}>
|
||
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-red)" $platform-web={{ borderRadius: '9999px' }} />
|
||
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-yellow)" $platform-web={{ borderRadius: '9999px' }} />
|
||
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-green)" $platform-web={{ borderRadius: '9999px' }} />
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" marginInlineStart={6}>
|
||
projects/main
|
||
</Text>
|
||
</XStack>
|
||
<View width={56} height={4} rounded={9999} bg="var(--white-10)" overflow="hidden" $platform-web={{ borderRadius: '9999px' }}>
|
||
<View height="100%" bg="var(--state-online)" width={`${Math.round((at / DEV_BEATS.length) * 100)}%`} $platform-web={{ borderRadius: '9999px', transition: 'width 400ms ease' }} />
|
||
</View>
|
||
</XStack>
|
||
<XStack px={10} py={5} items="center" gap={6} borderBottomWidth={1} borderColor="var(--white-05)" minH={26}>
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--art-iris)">
|
||
{at >= 1 ? <>▾ {t('thought for {time}', { time: '4.1s' })}</> : t('thinking…')}
|
||
</Text>
|
||
</XStack>
|
||
<XStack px={10} py={5} items="center" gap={6} minH={26}>
|
||
{at >= 2 ? (
|
||
<>
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--state-online)">
|
||
◆ {t('Edit')}
|
||
</Text>
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)">
|
||
src/middleware/auth.ts
|
||
</Text>
|
||
</>
|
||
) : null}
|
||
</XStack>
|
||
<YStack px={8} pb={6} minH={132} data-i18n-ltr>
|
||
{DIFF.slice(0, lines).map((l) => (
|
||
<XStack
|
||
key={l.n}
|
||
data-motion="rise"
|
||
gap={8}
|
||
px={4}
|
||
rounded={4}
|
||
bg={l.kind === 'same' ? 'transparent' : l.kind === 'cut' ? 'var(--state-error-bg)' : 'var(--white-10)'}
|
||
$platform-web={{ borderRadius: '4px' }}
|
||
>
|
||
<Text fontFamily="$mono" fontSize="$1" width={18} color={l.kind === 'cut' ? 'var(--state-error)' : l.kind === 'add' ? 'var(--state-online)' : 'var(--muted-foreground)'} opacity={l.kind === 'same' ? 0.5 : 1}>
|
||
{l.n}
|
||
</Text>
|
||
<Text fontFamily="$mono" fontSize="$1" color={l.kind === 'cut' ? 'var(--state-error)' : l.kind === 'add' ? 'var(--state-online)' : 'var(--foreground)'} $platform-web={{ whiteSpace: 'pre', textDecoration: l.kind === 'cut' ? 'line-through' : 'none' }}>
|
||
{l.text}
|
||
</Text>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
<XStack px={10} py={6} bg="var(--white-03)" borderTopWidth={1} borderColor="var(--border)" items="center" gap={6}>
|
||
<Text fontFamily="$mono" fontSize="$1" color={done ? 'var(--state-online)' : 'var(--foreground)'}>
|
||
{done ? <>✓ {t('Tests pass · 4 lines changed')}</> : <>› {t('Add rate limiting to all API routes.')}</>}
|
||
</Text>
|
||
</XStack>
|
||
</View>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
const BOT_STEPS = [
|
||
phrase('Read the month’s card charges'),
|
||
phrase('Matched each charge to its receipt'),
|
||
phrase('Asked about the ones with no receipt'),
|
||
phrase('Filed the month-end report'),
|
||
] as const
|
||
/** Each step runs, then the next. */
|
||
const BOT_BEATS = [600, 1300, 1300, 1500, 1300] as const
|
||
|
||
function BotPreview({ play, still }: { play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const at = useScene(BOT_BEATS, play, still)
|
||
return (
|
||
<YStack p={20} gap={14} flex={1} justify="center" minH={300}>
|
||
<XStack items="center" gap={10} px={14} py={8} rounded={9999} bg="var(--secondary)" self="flex-start" $platform-web={{ borderRadius: '9999px' }}>
|
||
<Clock size={14} color="var(--foreground)" />
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--foreground)">
|
||
{t('Routine · Month-end close')}
|
||
</Text>
|
||
</XStack>
|
||
<YStack gap={10}>
|
||
{BOT_STEPS.map((step, k) => {
|
||
const state = at > k + 1 ? 'done' : at === k + 1 ? 'doing' : 'waiting'
|
||
return (
|
||
<XStack key={step} items="center" gap={10} opacity={state === 'waiting' ? 0.4 : 1} $platform-web={{ transition: 'opacity 300ms ease' }}>
|
||
<View width={20} height={20} shrink={0} rounded={9999} items="center" justify="center" borderWidth={1} borderColor={state === 'done' ? 'var(--state-online)' : 'var(--border-strong)'} $platform-web={{ borderRadius: '9999px' }}>
|
||
{state === 'done' ? <Check size={12} color="var(--state-online)" strokeWidth={2.4} /> : state === 'doing' ? <Loader2 data-spin="" size={12} color="var(--foreground)" /> : null}
|
||
</View>
|
||
<Text fontSize="$2" color={state === 'doing' ? 'var(--foreground)' : 'var(--muted-foreground)'}>
|
||
{t(step)}
|
||
</Text>
|
||
</XStack>
|
||
)
|
||
})}
|
||
</YStack>
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" minH={18}>
|
||
{at >= BOT_BEATS.length ? t('Done. 2 questions waiting for you.') : t('Working…')}
|
||
</Text>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Studio's footage: the creative models' paintings (public/images/models),
|
||
* one after another, each easing in and drifting slowly as it holds, named by
|
||
* the model it shows. It moves only while it is on screen; a reader who asked
|
||
* for less motion sees the first, still.
|
||
*/
|
||
const REEL = [
|
||
{ src: '/images/models/iro.webp', model: 'Iro', kind: phrase('Image'), alt: phrase("Iro, Hanzo's image model") },
|
||
{ src: '/images/models/koma.webp', model: 'Koma', kind: phrase('Video'), alt: phrase("Koma, Hanzo's video model") },
|
||
{ src: '/images/models/mori.webp', model: 'Mori', kind: phrase('3D'), alt: phrase("Mori, Hanzo's 3d model") },
|
||
{ src: '/images/models/oto.webp', model: 'Oto', kind: phrase('Sound'), alt: phrase("Oto, Hanzo's sound model") },
|
||
{ src: '/images/models/koe.webp', model: 'Koe', kind: phrase('Voice'), alt: phrase("Koe, Hanzo's voice model") },
|
||
{ src: '/images/models/uta.webp', model: 'Uta', kind: phrase('Music'), alt: phrase("Uta, Hanzo's music model") },
|
||
] as const
|
||
/** Milliseconds each still holds before the next has fully come in. */
|
||
const HOLD = 2600
|
||
|
||
function StudioPreview({ play, still }: { play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const reel = useRef<GuiElement>(null)
|
||
useEffect(() => {
|
||
const box = node(reel)
|
||
if (!box || still || !play || typeof box.animate !== 'function') return
|
||
const cut = REEL.length * HOLD
|
||
const frames = [...box.querySelectorAll<HTMLElement>('[data-shot]')]
|
||
const runs = frames.map((el, i) =>
|
||
el.animate(
|
||
[
|
||
{ opacity: 0, transform: 'scale(1.08) translate3d(2%, 1%, 0)', offset: 0 },
|
||
{ opacity: 1, transform: 'scale(1.05) translate3d(1%, 0.5%, 0)', offset: 0.08 },
|
||
{ opacity: 1, transform: 'scale(1) translate3d(0, 0, 0)', offset: 1 / REEL.length },
|
||
{ opacity: 0, transform: 'scale(1) translate3d(0, 0, 0)', offset: Math.min(1, 1 / REEL.length + 0.08) },
|
||
{ opacity: 0, transform: 'scale(1) translate3d(0, 0, 0)', offset: 1 },
|
||
],
|
||
{ duration: cut, delay: i * HOLD, iterations: Infinity, easing: 'ease-in-out', fill: 'both' },
|
||
),
|
||
)
|
||
const io = new IntersectionObserver(([e]) => runs.forEach((r) => (e?.isIntersecting ? r.play() : r.pause())))
|
||
io.observe(box)
|
||
return () => {
|
||
io.disconnect()
|
||
runs.forEach((r) => r.cancel())
|
||
}
|
||
}, [play, still])
|
||
return (
|
||
<View flex={1} minH={300}>
|
||
<View ref={reel} flex={1} width="100%" overflow="hidden" position="relative" bg="var(--pure-black)" data-reel="">
|
||
{REEL.map((f, i) => (
|
||
<View key={f.src} data-shot="" position="absolute" t={0} l={0} r={0} b={0} opacity={i === 0 ? 1 : 0}>
|
||
<View
|
||
render={<img src={f.src} alt={t(f.alt)} loading="lazy" />}
|
||
$platform-web={{ objectFit: 'cover', display: 'block', width: '100%', height: '100%' }}
|
||
/>
|
||
<XStack position="absolute" insetInlineStart={12} b={12} items="center" gap={6} px={10} py={4} rounded={9999} $platform-web={{ borderRadius: '9999px', backdropFilter: 'blur(8px)', backgroundColor: 'color-mix(in srgb, var(--pure-black) 60%, transparent)' }}>
|
||
<Text fontFamily="$mono" fontSize="$1" fontWeight="600" color="var(--pure-white)">
|
||
{f.model}
|
||
</Text>
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--white-80)">
|
||
{t(f.kind)}
|
||
</Text>
|
||
</XStack>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Team's room: the team and their agent talking in one channel, a message at a
|
||
* time with someone typing between, the last four in view.
|
||
*/
|
||
const PEOPLE = [
|
||
{ who: 'Ada', tone: 'var(--art-iris)', agent: false },
|
||
{ who: 'Lin', tone: 'var(--art-tide)', agent: false },
|
||
{ who: 'Sam', tone: 'var(--art-ember)', agent: false },
|
||
{ who: 'Grace', tone: 'var(--state-online)', agent: false },
|
||
{ who: 'Kai', tone: 'var(--foreground)', agent: true },
|
||
] as const
|
||
const THREAD = [
|
||
{ who: 0, says: phrase('Pricing page is ready for review.') },
|
||
{ who: 1, says: phrase('Copy reads well. One nit on the hero.') },
|
||
{ who: 4, says: phrase('Tests pass on main. Deploy to production?') },
|
||
{ who: 3, says: phrase('Approved. Ship it.') },
|
||
{ who: 2, says: phrase('Release notes are in the doc.') },
|
||
{ who: 0, says: phrase('Live. Thanks, everyone.') },
|
||
] as const
|
||
/** Typing, then the message, for each. */
|
||
const TEAM_BEATS = THREAD.flatMap(() => [700, 900])
|
||
|
||
function TeamPreview({ play, still }: { play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const at = useScene(TEAM_BEATS, play, still, 3200)
|
||
const posted = Math.floor(at / 2)
|
||
const typing = at % 2 === 1 && posted < THREAD.length ? PEOPLE[THREAD[posted].who] : null
|
||
const view = THREAD.slice(0, posted).slice(-4)
|
||
return (
|
||
<YStack p={16} gap={12} flex={1} justify="flex-end" data-team="" minH={300}>
|
||
<XStack items="center" gap={8}>
|
||
{/* A channel's name in the sample room: a name, not copy, read in its own order. */}
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" data-i18n-ltr {...{ translate: 'no' }}>
|
||
# launch
|
||
</Text>
|
||
<View flex={1} height={1} bg="var(--white-06)" />
|
||
<XStack>
|
||
{PEOPLE.map((p, k) => (
|
||
<View key={p.who} width={22} height={22} marginInlineStart={k ? -6 : 0} rounded={9999} items="center" justify="center" borderWidth={2} borderColor="$panel" bg={p.agent ? 'var(--foreground)' : 'var(--secondary)'} $platform-web={{ borderRadius: '9999px' }}>
|
||
<Text fontSize={10} fontWeight="700" color={p.agent ? 'var(--background)' : p.tone}>
|
||
{p.who[0]}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</XStack>
|
||
</XStack>
|
||
<YStack gap={12} minH={220} justify="flex-end">
|
||
{view.map((m) => {
|
||
const p = PEOPLE[m.who]
|
||
return (
|
||
<XStack key={`${m.who}-${m.says}`} data-motion="rise" gap={10} items="flex-start">
|
||
<View width={28} height={28} shrink={0} rounded={9999} items="center" justify="center" bg={p.agent ? 'var(--foreground)' : 'var(--secondary)'} $platform-web={{ borderRadius: '9999px' }}>
|
||
<Text fontSize="$1" fontWeight="700" color={p.agent ? 'var(--background)' : p.tone}>
|
||
{p.who[0]}
|
||
</Text>
|
||
</View>
|
||
<YStack gap={2} flex={1} minW={0}>
|
||
<XStack items="center" gap={6}>
|
||
<Text fontSize="$2" fontWeight="600" color="var(--foreground)">
|
||
{p.who}
|
||
</Text>
|
||
{p.agent ? (
|
||
<Text fontFamily="$mono" fontSize="$1" px={6} rounded={6} bg="var(--white-08)" color="var(--muted-foreground)">
|
||
{t('agent')}
|
||
</Text>
|
||
) : null}
|
||
</XStack>
|
||
<Text fontSize="$2" lineHeight="$3" color="var(--muted-foreground)">
|
||
{t(m.says)}
|
||
</Text>
|
||
</YStack>
|
||
</XStack>
|
||
)
|
||
})}
|
||
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" minH={18}>
|
||
{typing ? t('{who} is typing…', { who: typing.who }) : ''}
|
||
</Text>
|
||
</YStack>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
function Card({ p, i, last, play, still }: { p: (typeof PRODUCTS)[number]; i: number; last: boolean; play: boolean; still: boolean }) {
|
||
const { t } = useI18n()
|
||
const ref = useGrow(play, still, { delay: i * 70, duration: 550, from: 'translateY(16px)', to: 'translateY(0px)' })
|
||
|
||
return (
|
||
<View
|
||
ref={ref}
|
||
data-product={p.key}
|
||
display="flex"
|
||
flexDirection="column"
|
||
// Two columns from 640px, the last tile across both; six from 1024px, three tiles over two.
|
||
$sm={last ? { gridColumn: 'span 2' } : undefined}
|
||
$lg={{ gridColumn: p.top ? 'span 2' : 'span 3' }}
|
||
>
|
||
<YStack
|
||
flex={1}
|
||
minH={380}
|
||
rounded={24}
|
||
bg="$panel"
|
||
borderWidth={1}
|
||
borderColor="var(--border)"
|
||
boxShadow="0 24px 60px -28px color-mix(in srgb, var(--pure-black) 45%, transparent)"
|
||
hoverStyle={{ borderColor: 'var(--border-strong)' }}
|
||
overflow="hidden"
|
||
justify="space-between"
|
||
$platform-web={{ borderRadius: '24px', transition: 'border-color 0.15s ease' }}
|
||
>
|
||
{p.key === 'chat' && <ChatPreview play={play} still={still} />}
|
||
{p.key === 'dev' && <DevPreview play={play} still={still} />}
|
||
{p.key === 'bot' && <BotPreview play={play} still={still} />}
|
||
{p.key === 'studio' && <StudioPreview play={play} still={still} />}
|
||
{p.key === 'team' && <TeamPreview play={play} still={still} />}
|
||
|
||
<XStack px={22} py={16} items="center" justify="space-between">
|
||
<Text fontSize="$5" lineHeight="$5" fontWeight="600" color="var(--foreground)">
|
||
{p.name}
|
||
</Text>
|
||
<XStack
|
||
render={<a href={p.href} />}
|
||
items="center"
|
||
gap={6}
|
||
opacity={0.75}
|
||
hoverStyle={{ opacity: 1 }}
|
||
$platform-web={{
|
||
textDecoration: 'none',
|
||
borderRadius: '9999px',
|
||
}}
|
||
>
|
||
<Text fontSize="$2" fontWeight="500" color="var(--foreground)">
|
||
{t('Explore')}
|
||
</Text>
|
||
<ArrowUpRight size={16} color="var(--foreground)" data-i18n-mirror />
|
||
</XStack>
|
||
</XStack>
|
||
</YStack>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
export default function Products() {
|
||
const { t } = useI18n()
|
||
const [ref, seen] = useSeen(0.15)
|
||
const still = useStill()
|
||
|
||
return (
|
||
<Section id="products" label="Hanzo.Apps" title={t('One account. Every tool you build with.')} dither>
|
||
<View
|
||
ref={ref}
|
||
display="grid"
|
||
width="100%"
|
||
gridTemplateColumns="minmax(0, 1fr)"
|
||
gap={16}
|
||
$sm={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 20 }}
|
||
$lg={{ gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 24 }}
|
||
>
|
||
{PRODUCTS.map((p, i) => (
|
||
<Card key={p.key} p={p} i={i} last={i === PRODUCTS.length - 1} play={seen} still={still} />
|
||
))}
|
||
</View>
|
||
</Section>
|
||
)
|
||
}
|