mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
218 lines
7.6 KiB
TypeScript
218 lines
7.6 KiB
TypeScript
import type { ComponentProps, ReactElement, ReactNode } from 'react'
|
|
import Link from 'next/link'
|
|
import { notFound } from 'next/navigation'
|
|
import { featured } from '@/lib/data/customers'
|
|
import { testimonials } from '@/lib/data/testimonials'
|
|
import { pageMeta } from '@/lib/page-meta'
|
|
import { Box, Text, View, XStack } from '@/components/ui'
|
|
|
|
const told = featured.filter((s) => s.full)
|
|
|
|
export const dynamicParams = false
|
|
|
|
export function generateStaticParams() {
|
|
return told.map((s) => ({ id: s.id }))
|
|
}
|
|
|
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params
|
|
const s = told.find((x) => x.id === id)
|
|
if (!s?.full) return {}
|
|
return pageMeta({ title: `${s.company} · ${s.figures[0].value} ${s.figures[0].label}`, description: s.full.lede, path: `/customers/${s.id}` })
|
|
}
|
|
|
|
type TextProps = ComponentProps<typeof Text>
|
|
|
|
/** The page's measure and gutter. */
|
|
const WRAP = { width: '100%', maxW: 1080, mx: 'auto', px: 24 } as const
|
|
|
|
/** As many columns as fit at `min` wide, never wider than the page. */
|
|
const cells = (min: number) => `repeat(auto-fit, minmax(min(100%, ${min}px), 1fr))`
|
|
|
|
/** The mono small caps over every part: a date, a section, a chapter number. */
|
|
function Label({ children, ...p }: { children: ReactNode } & Omit<TextProps, 'children'>) {
|
|
return (
|
|
<Text
|
|
render="p"
|
|
display="block"
|
|
fontFamily="$mono"
|
|
fontSize="$1"
|
|
lineHeight="1.25rem"
|
|
letterSpacing={0.88}
|
|
textTransform="uppercase"
|
|
color="var(--muted-foreground)"
|
|
{...p}
|
|
>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
/** A paragraph of the story, at the relaxed leading. */
|
|
function Body({ children, ...p }: { children: ReactNode } & Omit<TextProps, 'children'>) {
|
|
return (
|
|
<Text render="p" display="block" fontSize="$3" lineHeight="1.4219rem" color="var(--muted-foreground)" {...p}>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
/** A picture with the page's hairline and a small corner. */
|
|
function Picture({ src, alt, lazy, ...p }: { src: string; alt: string; lazy?: boolean } & ComponentProps<typeof View>) {
|
|
return (
|
|
<View
|
|
render={<img src={src} alt={alt} loading={lazy ? 'lazy' : undefined} />}
|
|
display="block"
|
|
width="100%"
|
|
height="auto"
|
|
rounded={8}
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
{...p}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/** A hairline box on the page's first surface: a figure, a way out. */
|
|
const BOX = { borderWidth: 1, borderColor: 'var(--border)', rounded: 'var(--radius-lg, 12px)' } as const
|
|
const SURFACE = { backgroundColor: 'var(--surface-1, transparent)' } as const
|
|
|
|
/** A way out of the story, as a quiet bordered link. */
|
|
function Out({ to, children }: { to: ReactElement; children: ReactNode }) {
|
|
return (
|
|
<Text
|
|
render={to}
|
|
display="block"
|
|
{...BOX}
|
|
py={12}
|
|
px={18}
|
|
fontSize="$2"
|
|
lineHeight="1.25rem"
|
|
color="var(--foreground)"
|
|
textDecorationLine="none"
|
|
$platform-web={SURFACE}
|
|
>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export default async function Story({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params
|
|
const s = told.find((x) => x.id === id)
|
|
if (!s?.full) notFound()
|
|
const f = s.full
|
|
const t = testimonials.find((q) => s.company.startsWith(q.company))
|
|
return (
|
|
<Box render="main" pt={64} pb={96}>
|
|
<Box render="header" {...WRAP}>
|
|
<Label render={<Link href="/customers" />} display="inline-flex" textDecorationLine="none">
|
|
← Customers
|
|
</Label>
|
|
<Label mt={28}>{[s.year, s.kind].filter(Boolean).join(' · ')}</Label>
|
|
<Text
|
|
render="h1"
|
|
display="block"
|
|
mt={12}
|
|
mb={16}
|
|
fontWeight="600"
|
|
color="var(--foreground)"
|
|
$platform-web={{ fontSize: 'clamp(var(--text-4xl), 5vw, var(--text-7xl))', lineHeight: '1.05', letterSpacing: '-0.03em' }}
|
|
>
|
|
{s.company}
|
|
</Text>
|
|
<Body maxW={720} fontSize="$4" lineHeight="1.5234rem">
|
|
{f.lede}
|
|
</Body>
|
|
<Picture src={f.cover.src} alt={f.cover.alt} mt={32} aspectRatio={21 / 9} $platform-web={{ objectFit: 'cover' }} />
|
|
<View display="grid" gridTemplateColumns={cells(200)} gap={12} mt={12}>
|
|
{s.figures.map((x) => (
|
|
<Box key={x.label} {...BOX} p={18} $platform-web={SURFACE}>
|
|
<Text render="p" display="block" fontSize="$8" lineHeight="1.95rem" fontWeight="600" color="var(--foreground)" $platform-web={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{x.value}
|
|
</Text>
|
|
<Body mt={4} fontSize="$2" lineHeight="1.3203rem">
|
|
{x.label}
|
|
</Body>
|
|
</Box>
|
|
))}
|
|
</View>
|
|
</Box>
|
|
|
|
{f.report ? (
|
|
<View render="section" {...WRAP} mt={64} display="grid" gap={32} items="center" gridTemplateColumns={cells(360)}>
|
|
<Box>
|
|
<Label render="h2" fontWeight="700">
|
|
The platform’s own report
|
|
</Label>
|
|
<Body mt={12}>
|
|
The campaign as Hanzo reported it after the first month: reach, spend, bikes presold and sold, and engagement by channel.
|
|
</Body>
|
|
</Box>
|
|
<Picture src={f.report.src} alt={f.report.alt} />
|
|
</View>
|
|
) : null}
|
|
|
|
<Box render="section" {...WRAP} mt={64}>
|
|
<Label render="h2" fontWeight="700">
|
|
How it went
|
|
</Label>
|
|
<View render="ol" display="grid" gap={40} mt={20} mb={0} p={0} $platform-web={{ listStyle: 'none' }}>
|
|
{f.chapters.map((c, i) => (
|
|
<View key={c.title} render="li" display="grid" gap={24} items="center" gridTemplateColumns={cells(360)}>
|
|
<Box>
|
|
<Label>{String(i + 1).padStart(2, '0')}</Label>
|
|
<Text render="h3" display="block" mt={6} mb={10} fontSize="$7" lineHeight="1.575rem" fontWeight="500" color="var(--foreground)">
|
|
{c.title}
|
|
</Text>
|
|
<Body>{c.body}</Body>
|
|
</Box>
|
|
{c.picture ? <Picture src={c.picture.src} alt={c.picture.alt} lazy maxH={420} $platform-web={{ objectFit: 'cover' }} /> : null}
|
|
</View>
|
|
))}
|
|
</View>
|
|
</Box>
|
|
|
|
{f.gallery.length ? (
|
|
<Box render="section" {...WRAP} mt={64}>
|
|
<Label render="h2" fontWeight="700">
|
|
From the campaign
|
|
</Label>
|
|
<View display="grid" gridTemplateColumns={cells(220)} gap={12} mt={20} items="flex-start">
|
|
{f.gallery.map((g) => (
|
|
<Picture key={g.src} src={g.src} alt={g.alt} lazy />
|
|
))}
|
|
</View>
|
|
</Box>
|
|
) : null}
|
|
|
|
{t ? (
|
|
<Box render="section" {...WRAP} mt={64}>
|
|
<Box render="figure" m={0} borderLeftWidth={1} borderColor="var(--border)" pl="max(16px, min(3vw, 32px))">
|
|
<Text
|
|
render="blockquote"
|
|
display="block"
|
|
m={0}
|
|
fontSize="$7"
|
|
lineHeight="1.8375rem"
|
|
color="var(--foreground)"
|
|
$platform-web={{ fontFamily: 'var(--font-serif, Georgia, serif)' }}
|
|
>
|
|
“{t.quote}”
|
|
</Text>
|
|
<Box render="figcaption" mt={16}>
|
|
<Body color="var(--foreground)">{t.author}</Body>
|
|
<Label>{t.role}</Label>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
|
|
<XStack render="section" {...WRAP} mt={64} flexWrap="wrap" gap={12}>
|
|
<Out to={<a href={f.pdf} />}>The original case study (PDF)</Out>
|
|
<Out to={<Link href="/customers" />}>Every launch →</Out>
|
|
</XStack>
|
|
</Box>
|
|
)
|
|
}
|