[Website] Replace product page hero visual with interactive CRM depicting AI chat in action. (#20566)

Before:

<img width="1439" height="518" alt="image"
src="https://github.com/user-attachments/assets/4d294a1b-c5a0-43b2-9895-61a8ee19da62"
/>

After:


https://github.com/user-attachments/assets/c019586f-ef9f-4ae0-8afe-14f08e8cb057
This commit is contained in:
Abdullah.
2026-05-14 12:48:14 +05:00
committed by GitHub
parent ab705b14d7
commit 61683d8bda
12 changed files with 1034 additions and 88 deletions

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -98,11 +98,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
i18n.locale as AppLocale,
)}
/>
<link
as="fetch"
href="/illustrations/product/hero/hero.glb"
rel="preload"
/>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
@@ -137,7 +132,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
variant="contained"
/>
</Hero.Cta>
<Hero.ProductVisual />
<Hero.ProductVisual visual={APP_PREVIEW_DATA.visual} />
</Hero.Root>
<TrustedBy.Root

View File

@@ -71,6 +71,7 @@ export const SHARED_PEOPLE_AVATAR_URLS = {
reidHoffman: '/images/shared/people/avatars/reid-hoffman.webp',
roelofBotha: '/images/shared/people/avatars/roelof-botha.webp',
ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.webp',
samAltman: '/images/shared/people/avatars/sam-altman.webp',
steveAnavi: '/images/shared/people/avatars/steve-anavi.webp',
stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.webp',
sundarPichai: '/images/shared/people/avatars/sundar-pichai.webp',

View File

@@ -3,7 +3,7 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { ACCENT } from './dashboard-visual.data';
import { ACCENT, ACCENT_SECONDARY } from './dashboard-visual.data';
import {
BG_PANEL,
BORDER_COLOR,
@@ -16,11 +16,11 @@ const Panel = styled.div`
border: 1px solid ${BORDER_COLOR};
border-radius: 10px;
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
gap: 10px;
height: 100%;
min-width: 0;
padding: 18px 20px;
padding: 14px 16px;
`;
const PanelTitle = styled.span`
@@ -30,13 +30,60 @@ const PanelTitle = styled.span`
letter-spacing: 0.02em;
`;
const ChartWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const ChartBody = styled.div`
display: flex;
flex: 1;
min-height: 0;
`;
const YAxisTitle = styled.span`
align-self: center;
color: ${TEXT_MUTED};
font-size: 9px;
letter-spacing: 0.03em;
margin-right: 12px;
white-space: nowrap;
writing-mode: vertical-rl;
transform: rotate(180deg);
`;
const YAxis = styled.div`
display: flex;
flex-direction: column;
gap: 32px;
justify-content: flex-end;
padding-bottom: 20px;
padding-right: 6px;
`;
const YLabel = styled.span`
color: ${TEXT_MUTED};
font-size: 9px;
font-variant-numeric: tabular-nums;
line-height: 1;
text-align: right;
`;
const BarsContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
`;
const BarsArea = styled.div`
align-items: flex-end;
display: flex;
flex: 1;
gap: 5px;
gap: 4px;
min-height: 0;
padding-top: 20px;
`;
const BarColumn = styled.div`
@@ -44,10 +91,29 @@ const BarColumn = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
gap: 4px;
height: 100%;
justify-content: flex-end;
position: relative;
`;
const BarPair = styled.div`
align-items: flex-end;
display: flex;
flex: 1;
gap: 2px;
justify-content: center;
min-height: 0;
width: 100%;
`;
const BarWithLabel = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
justify-content: flex-end;
width: 40%;
`;
const Bar = styled.div`
@@ -57,18 +123,15 @@ const Bar = styled.div`
transition:
height 0.8s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.15s ease;
width: 80%;
width: 100%;
`;
const BarValue = styled.span`
color: ${TEXT_SECONDARY};
font-size: 9px;
font-size: 8px;
font-variant-numeric: tabular-nums;
font-weight: 500;
position: absolute;
top: 0;
transform: translateY(calc(-100% - 4px));
white-space: nowrap;
line-height: 1;
`;
const BarLabel = styled.span`
@@ -78,9 +141,18 @@ const BarLabel = styled.span`
letter-spacing: 0.02em;
`;
const XAxisTitle = styled.div`
color: ${TEXT_MUTED};
font-size: 9px;
letter-spacing: 0.03em;
padding-top: 6px;
text-align: center;
`;
type BarDatum = {
label: string;
value: number;
value2: number;
};
type BarChartProps = {
@@ -88,41 +160,85 @@ type BarChartProps = {
data: BarDatum[];
};
const Y_TICK_COUNT = 5;
export function BarChart({ active, data }: BarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const rawMax = Math.max(...data.map((datum) => datum.value));
const maxValue = rawMax * 1.3;
const allValues = data.flatMap((datum) => [datum.value, datum.value2]);
const maxValue = Math.max(...allValues) * 1.05;
const tickStep = Math.ceil(maxValue / Y_TICK_COUNT);
const yTicks = Array.from(
{ length: Y_TICK_COUNT + 1 },
(_, i) => i * tickStep,
);
return (
<Panel>
<PanelTitle>Monthly volume</PanelTitle>
<BarsArea>
{data.map((datum, index) => {
const heightPercent = (datum.value / maxValue) * 100;
const isHovered = hoveredIndex === index;
<PanelTitle>Widget name</PanelTitle>
<ChartWrapper>
<ChartBody>
<YAxisTitle>Values</YAxisTitle>
<YAxis>
{[...yTicks].reverse().map((tick) => (
<YLabel key={tick}>{tick === 0 ? '0' : `${tick}K`}</YLabel>
))}
</YAxis>
<BarsContainer>
<BarsArea>
{data.map((datum, index) => {
const h1 = (datum.value / maxValue) * 100;
const h2 = (datum.value2 / maxValue) * 100;
const isHovered = hoveredIndex === index;
return (
<BarColumn key={index}>
{active ? (
<BarValue style={{ opacity: isHovered ? 1 : 0.7 }}>
{datum.value}
</BarValue>
) : null}
<Bar
onPointerEnter={() => setHoveredIndex(index)}
onPointerLeave={() => setHoveredIndex(null)}
style={{
backgroundColor: ACCENT,
filter: isHovered ? 'brightness(1.3)' : 'none',
height: active ? `${heightPercent}%` : '0%',
transitionDelay: active ? `${index * 50}ms` : '0ms',
}}
/>
<BarLabel>{datum.label}</BarLabel>
</BarColumn>
);
})}
</BarsArea>
return (
<BarColumn
key={index}
onPointerEnter={() => setHoveredIndex(index)}
onPointerLeave={() => setHoveredIndex(null)}
>
<BarPair>
<BarWithLabel>
{active ? (
<BarValue style={{ opacity: isHovered ? 1 : 0.7 }}>
{datum.value}
</BarValue>
) : null}
<Bar
style={{
backgroundColor: ACCENT,
filter: isHovered ? 'brightness(1.2)' : 'none',
height: active ? `${h1}%` : '0%',
transitionDelay: active ? `${index * 50}ms` : '0ms',
}}
/>
</BarWithLabel>
<BarWithLabel>
{active ? (
<BarValue style={{ opacity: isHovered ? 1 : 0.7 }}>
{datum.value2}
</BarValue>
) : null}
<Bar
style={{
backgroundColor: ACCENT_SECONDARY,
filter: isHovered ? 'brightness(1.2)' : 'none',
height: active ? `${h2}%` : '0%',
transitionDelay: active
? `${index * 50 + 25}ms`
: '0ms',
}}
/>
</BarWithLabel>
</BarPair>
<BarLabel>{datum.label}</BarLabel>
</BarColumn>
);
})}
</BarsArea>
<XAxisTitle>Months</XAxisTitle>
</BarsContainer>
</ChartBody>
</ChartWrapper>
</Panel>
);
}

View File

@@ -5,20 +5,109 @@ import { styled } from '@linaria/react';
import { BarChart } from './BarChart';
import { BAR_DATA, DONUT_VALUE } from './dashboard-visual.data';
import { DonutChart } from './DonutChart';
import { WindowChrome } from './WindowChrome';
import {
BG_DARK,
BG_PANEL,
BORDER_COLOR,
TEXT_MUTED,
TEXT_PRIMARY,
TEXT_SECONDARY,
} from './visual-tokens';
const Window = styled.div`
background-color: ${BG_DARK};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
const Topbar = styled.div`
align-items: center;
border-bottom: 1px solid ${BORDER_COLOR};
display: flex;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
`;
const BreadcrumbNav = styled.div`
align-items: center;
display: flex;
gap: 6px;
`;
const BreadcrumbIcon = styled.span`
align-items: center;
color: ${TEXT_SECONDARY};
display: flex;
`;
const BreadcrumbText = styled.span`
color: ${TEXT_SECONDARY};
font-size: 12px;
letter-spacing: 0.01em;
`;
const BreadcrumbBold = styled.span`
color: ${TEXT_PRIMARY};
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
`;
const TopbarActions = styled.div`
align-items: center;
display: flex;
gap: 8px;
margin-left: auto;
`;
const ActionBtn = styled.span`
align-items: center;
border: 1px solid ${BORDER_COLOR};
border-radius: 4px;
color: ${TEXT_SECONDARY};
display: flex;
font-size: 11px;
gap: 4px;
padding: 4px 8px;
`;
const ActionIcon = styled.span`
color: ${TEXT_MUTED};
display: flex;
`;
const Body = styled.div`
display: grid;
flex: 1;
gap: 12px;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr 60px;
min-height: 0;
padding: 14px;
`;
@media (max-width: 600px) {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
const BarChartCell = styled.div`
grid-column: 2;
grid-row: 1 / -1;
min-height: 0;
`;
const BottomPanel = styled.div`
background-color: ${BG_PANEL};
border: 1px solid ${BORDER_COLOR};
border-radius: 10px;
padding: 14px 16px;
`;
const WidgetTitle = styled.span`
color: ${TEXT_SECONDARY};
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
`;
type DashboardVisualProps = {
@@ -27,15 +116,61 @@ type DashboardVisualProps = {
export function DashboardVisual({ active }: DashboardVisualProps) {
return (
<WindowChrome
breadcrumb="Dashboard"
breadcrumbBold="Sales performance"
title="Apple"
>
<Window>
<Topbar>
<BreadcrumbNav>
<BreadcrumbIcon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
</BreadcrumbIcon>
<BreadcrumbText>Dashboard /</BreadcrumbText>
<BreadcrumbBold>Sales performances</BreadcrumbBold>
</BreadcrumbNav>
<TopbarActions>
<ActionBtn>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
Delete
</ActionBtn>
<ActionIcon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</ActionIcon>
<ActionBtn>K</ActionBtn>
</TopbarActions>
</Topbar>
<Body>
<DonutChart active={active} value={DONUT_VALUE} />
<BarChart active={active} data={BAR_DATA} />
<BarChartCell>
<BarChart active={active} data={BAR_DATA} />
</BarChartCell>
<BottomPanel>
<WidgetTitle>Widget name</WidgetTitle>
</BottomPanel>
</Body>
</WindowChrome>
</Window>
);
}

View File

@@ -92,7 +92,7 @@ export function DonutChart({ active, value }: DonutChartProps) {
return (
<Panel>
<PanelTitle>Conversion rate</PanelTitle>
<PanelTitle>Widget name</PanelTitle>
<ChartArea>
<svg height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} width={SIZE}>
<circle

View File

@@ -1,19 +1,15 @@
export const ACCENT = '#16a34a';
export const ACCENT_DIM = 'rgba(22, 163, 74, 0.25)';
export const ACCENT = '#4A38F5';
export const ACCENT_DIM = 'rgba(74, 56, 245, 0.25)';
export const ACCENT_SECONDARY = '#7938B2';
export const BAR_DATA = [
{ label: 'Jan', value: 38 },
{ label: 'Feb', value: 41 },
{ label: 'Mar', value: 40 },
{ label: 'Apr', value: 88 },
{ label: 'May', value: 44 },
{ label: 'Jun', value: 30 },
{ label: 'Jul', value: 39 },
{ label: 'Aug', value: 44 },
{ label: 'Sep', value: 30 },
{ label: 'Oct', value: 30 },
{ label: 'Nov', value: 41 },
{ label: 'Dec', value: 43 },
{ label: 'Jan', value: 36, value2: 38 },
{ label: 'Feb', value: 68, value2: 41 },
{ label: 'Mar', value: 40, value2: 38 },
{ label: 'Apr', value: 44, value2: 30 },
{ label: 'May', value: 39, value2: 40 },
{ label: 'Jun', value: 30, value2: 30 },
{ label: 'Jul', value: 44, value2: 30 },
];
export const DONUT_VALUE = 70;

View File

@@ -1,11 +0,0 @@
import { WebGlMount } from '@/lib/visual-runtime';
import { Product } from '@/sections/Hero/visuals/components/Product';
import { ProductPlaceholder } from '@/sections/Hero/visuals/components/ProductPlaceholder';
export function ProductVisual() {
return (
<WebGlMount priority fallback={<ProductPlaceholder />}>
<Product />
</WebGlMount>
);
}

View File

@@ -0,0 +1,469 @@
'use client';
import { styled } from '@linaria/react';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { COLORS } from '@/sections/AppPreview/Shared/utils/app-preview-theme';
import { VISUAL_TOKENS } from '@/sections/AppPreview/Shared/utils/app-preview-tokens';
import { AppPreviewNavbar } from '@/sections/AppPreview/Shell/AppPreviewNavbar';
import { AppPreviewSidebar } from '@/sections/AppPreview/Shell/AppPreviewSidebar';
import { AppPreviewViewbar } from '@/sections/AppPreview/Shell/AppPreviewViewbar';
import { renderPageDefinition } from '@/sections/AppPreview/Shell/PageRenderers';
import { AppWindow } from '@/sections/AppPreview/AppWindow/AppWindow';
import { WindowOrderProvider } from '@/sections/AppPreview/WindowOrder/WindowOrderProvider';
import { theme } from '@/theme';
import { PROMPT_OPTIONS } from './product-visual.data';
import { useProductVisualAutoplay } from './use-product-visual-autoplay';
const StyledRoot = styled.div`
isolation: isolate;
margin-top: ${theme.spacing(5)};
position: relative;
text-align: left;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
margin-top: ${theme.spacing(8)};
}
`;
const ShellScene = styled.div`
aspect-ratio: 1 / 1;
margin: 0 auto;
max-height: 740px;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
aspect-ratio: 1280 / 832;
}
`;
const AppLayout = styled.div`
display: flex;
flex: 1 1 auto;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
width: 100%;
z-index: 1;
`;
const RightColumn = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 12px;
min-height: 0;
min-width: 0;
padding: 12px 12px 12px 0;
`;
const ContentRow = styled.div`
display: flex;
flex: 1 1 auto;
gap: 8px;
min-height: 0;
`;
const IndexSurface = styled.div`
background: ${COLORS.background};
border: 1px solid ${COLORS.border};
border-radius: 8px;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
[aria-label*='workflow'] > div > div {
left: 0;
transform: scale(0.65) translateX(-20%);
transform-origin: top left;
}
`;
const AiPanel = styled.aside`
background: ${VISUAL_TOKENS.background.primary};
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
border-radius: 8px;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
min-height: 0;
overflow: hidden;
width: 280px;
@media (max-width: ${theme.breakpoints.md}px) {
display: none;
}
`;
const AiPanelHeader = styled.div`
align-items: center;
background-color: ${VISUAL_TOKENS.background.secondary};
border-bottom: 1px solid ${VISUAL_TOKENS.border.color.medium};
display: flex;
flex-shrink: 0;
gap: 4px;
height: 40px;
padding: 0 8px;
`;
const AiHeaderBtn = styled.span`
align-items: center;
border-radius: 4px;
color: ${VISUAL_TOKENS.font.color.secondary};
display: flex;
height: 28px;
justify-content: center;
width: 28px;
`;
const AiPanelTitle = styled.span`
color: ${VISUAL_TOKENS.font.color.primary};
flex: 1;
font-size: 13px;
font-weight: 600;
text-align: center;
`;
const AiMessages = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding: 12px;
`;
const UserMsg = styled.div`
background: ${VISUAL_TOKENS.background.transparent.medium};
border-radius: ${VISUAL_TOKENS.border.radius.sm};
color: ${VISUAL_TOKENS.font.color.secondary};
font-size: 13px;
font-weight: 500;
line-height: 1.4em;
padding: 4px 8px;
width: fit-content;
`;
const AiMsg = styled.div`
color: ${VISUAL_TOKENS.font.color.primary};
font-size: 13px;
font-weight: 400;
line-height: 1.4em;
width: 100%;
`;
const ThinkingText = styled.span`
color: ${VISUAL_TOKENS.font.color.tertiary};
font-size: 13px;
`;
const PromptOption = styled.button`
align-items: center;
background: none;
border: none;
border-radius: 4px;
color: ${VISUAL_TOKENS.font.color.primary};
cursor: pointer;
display: flex;
font-size: 13px;
gap: 8px;
line-height: 1.4;
padding: 6px 4px;
text-align: left;
width: 100%;
&:hover {
background: ${VISUAL_TOKENS.background.transparent.light};
}
`;
const PromptOptionIcon = styled.span`
align-items: center;
color: ${VISUAL_TOKENS.font.color.secondary};
display: flex;
flex-shrink: 0;
`;
const PromptOptions = styled.div`
display: flex;
flex-direction: column;
padding: 0 12px;
`;
const AiInputArea = styled.div`
align-items: flex-end;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 8px;
padding: 12px;
`;
const AiInputBox = styled.div`
background-color: ${VISUAL_TOKENS.background.transparent.lighter};
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 100px;
padding: 12px;
width: 100%;
`;
const AiInputPlaceholder = styled.span`
color: ${VISUAL_TOKENS.font.color.light};
font-size: 13px;
font-weight: 400;
`;
const AiInputBtnRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
margin-top: auto;
`;
const AiInputLeftBtns = styled.div`
align-items: center;
color: ${VISUAL_TOKENS.font.color.tertiary};
display: flex;
gap: 2px;
`;
const AiInputRightBtns = styled.div`
align-items: center;
display: flex;
gap: 4px;
`;
const ModelChip = styled.span`
align-items: center;
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
border-radius: 6px;
color: ${VISUAL_TOKENS.font.color.secondary};
display: flex;
font-size: 11px;
gap: 4px;
padding: 3px 8px;
`;
const SendBtn = styled.span`
align-items: center;
background: ${VISUAL_TOKENS.border.color.medium};
border-radius: 50%;
color: ${VISUAL_TOKENS.font.color.tertiary};
display: flex;
height: 24px;
justify-content: center;
width: 24px;
`;
type ProductVisualProps = {
visual: AppPreviewConfig;
};
export function ProductVisual({ visual }: ProductVisualProps) {
const {
activeItem,
activeLabel,
displayPage,
handleOptionSelect,
handleSelectLabel,
handleToggleFolder,
highlightedItemId,
openFolderIds,
revealedObjectIds,
selectedOption,
streamComplete,
streamedText,
workspaceNav,
} = useProductVisualAutoplay(visual);
const activeHeader = displayPage?.header;
const showViewBar =
displayPage !== null &&
displayPage !== undefined &&
displayPage.type !== 'dashboard' &&
displayPage.type !== 'workflow';
return (
<StyledRoot>
<ShellScene>
<WindowOrderProvider>
<AppWindow>
<AppLayout>
<AppPreviewSidebar
favoritesNav={visual.favoritesNav}
highlightedItemId={highlightedItemId ?? undefined}
onSelectLabel={handleSelectLabel}
onToggleFolder={handleToggleFolder}
openFolderIds={openFolderIds}
selectedLabel={activeLabel}
workspaceName={visual.workspace.name}
workspaceNav={workspaceNav}
/>
<RightColumn>
<AppPreviewNavbar
activeItem={activeItem}
activeLabel={activeLabel}
navbarActions={activeHeader?.navbarActions}
revealedObjectIds={revealedObjectIds}
/>
<ContentRow>
<IndexSurface>
{showViewBar ? (
<AppPreviewViewbar
actions={activeHeader?.actions ?? []}
count={activeHeader?.count}
pageType={displayPage.type}
showListIcon={activeHeader?.showListIcon ?? false}
title={activeHeader?.title ?? activeLabel}
/>
) : null}
{displayPage
? renderPageDefinition(
displayPage,
handleSelectLabel,
activeItem?.id ?? activeLabel,
)
: null}
</IndexSurface>
<AiPanel>
<AiPanelHeader>
<AiHeaderBtn>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</AiHeaderBtn>
<AiPanelTitle>Ask AI</AiPanelTitle>
<AiHeaderBtn>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</AiHeaderBtn>
</AiPanelHeader>
<AiMessages>
<UserMsg>{PROMPT_OPTIONS[selectedOption].label}</UserMsg>
{streamedText ? (
<AiMsg>{streamedText}</AiMsg>
) : (
<ThinkingText>Thinking...</ThinkingText>
)}
</AiMessages>
{streamComplete ? (
<PromptOptions>
{PROMPT_OPTIONS.filter(
(_, index) => index !== selectedOption,
).map((option, index) => (
<PromptOption
key={index}
onClick={() =>
handleOptionSelect(PROMPT_OPTIONS.indexOf(option))
}
>
<PromptOptionIcon>{option.icon}</PromptOptionIcon>
{option.label}
</PromptOption>
))}
</PromptOptions>
) : null}
<AiInputArea>
<AiInputBox>
<AiInputPlaceholder>
Ask, search or make anything...
</AiInputPlaceholder>
<AiInputBtnRow>
<AiInputLeftBtns>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
</svg>
</AiInputLeftBtns>
<AiInputRightBtns>
<ModelChip>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="#D97757"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fillRule="nonzero"
/>
</svg>
Claude Haiku 4.5
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</ModelChip>
<SendBtn>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</SendBtn>
</AiInputRightBtns>
</AiInputBtnRow>
</AiInputBox>
</AiInputArea>
</AiPanel>
</ContentRow>
</RightColumn>
</AppLayout>
</AppWindow>
</WindowOrderProvider>
</ShellScene>
</StyledRoot>
);
}

View File

@@ -0,0 +1,127 @@
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
export const NEW_COMPANY_ROW = {
id: 'openai',
cells: {
company: { type: 'entity' as const, name: 'OpenAI', domain: 'openai.com' },
url: { type: 'link' as const, value: 'openai.com' },
createdBy: {
type: 'person' as const,
name: 'AI Agent',
tone: 'gray' as const,
kind: 'system' as const,
shortLabel: 'AI',
},
address: { type: 'text' as const, value: '3180 18th St' },
accountOwner: {
type: 'person' as const,
name: 'Sam Altman',
tone: 'amber' as const,
kind: 'person' as const,
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
},
icp: { type: 'boolean' as const, value: true },
arr: { type: 'number' as const, value: '$2,000,000' },
linkedin: { type: 'link' as const, value: 'openai' },
industry: { type: 'tag' as const, value: 'AI Research' },
mainContact: {
type: 'person' as const,
name: 'Sam Altman',
shortLabel: 'S',
tone: 'amber' as const,
kind: 'person' as const,
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
},
employees: { type: 'number' as const, value: '3,500' },
opportunities: { type: 'relation' as const, items: [] },
added: { type: 'text' as const, value: 'Just now' },
},
};
export const NEW_PERSON_ROW = {
id: 'sam-altman',
cells: {
name: {
type: 'person' as const,
name: 'Sam Altman',
tone: 'amber' as const,
kind: 'person' as const,
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
},
company: { type: 'entity' as const, name: 'OpenAI', domain: 'openai.com' },
email: { type: 'link' as const, value: 'sam@openai.com' },
phone: { type: 'text' as const, value: '+1 415 555 0199' },
jobTitle: { type: 'text' as const, value: 'CEO' },
city: { type: 'text' as const, value: 'San Francisco' },
linkedin: { type: 'link' as const, value: 'sama' },
added: { type: 'text' as const, value: 'Just now' },
},
};
export const PROMPT_OPTIONS = [
{
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
),
label: 'Add a new lead',
navSteps: [
{ at: 0.25, target: 'Companies' },
{ at: 0.65, target: 'People' },
],
response:
'Adding OpenAI as a new company. Setting domain to openai.com, industry to AI Research, and ARR to $2,000,000. Account owner assigned to Sam Altman. Company record is live in your CRM.\n\nNow creating the contact — adding Sam Altman as CEO at OpenAI, based in San Francisco. Person record linked to the company.',
},
{
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
),
label: 'Create a dashboard',
navSteps: [
{ at: 0.35, target: 'Dashboards' },
{ at: 0.75, target: 'Sales Dashboard' },
],
response:
"Building a Sales Performance dashboard. I'll include revenue by month, new subscriptions, churn rate, and a distribution chart. Navigating to dashboards now... Opening the Sales Dashboard — your metrics are ready.",
},
{
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15 1.65 1.65 0 003 14.08V14a2 2 0 014 0v.09" />
</svg>
),
label: 'Set up a workflow',
navSteps: [{ at: 0.45, target: 'Create company when adding a new person' }],
response:
'Creating an automation: when a new person is added, automatically create their company record and link them. Setting the trigger and actions now... Workflow is active and ready to run.',
},
];

View File

@@ -0,0 +1,118 @@
import { useCallback, useEffect, useState } from 'react';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { useAppPreviewState } from '@/sections/AppPreview/Shell/use-app-preview-state';
import {
NEW_COMPANY_ROW,
NEW_PERSON_ROW,
PROMPT_OPTIONS,
} from './product-visual.data';
export function useProductVisualAutoplay(visual: AppPreviewConfig) {
const [selectedOption, setSelectedOption] = useState<number>(0);
const [streamedText, setStreamedText] = useState('');
const [streamComplete, setStreamComplete] = useState(false);
const [companyAdded, setCompanyAdded] = useState(false);
const [personAdded, setPersonAdded] = useState(false);
const {
activeItem,
activeLabel,
activePage,
handleSelectLabel,
handleToggleFolder,
highlightedItemId,
openFolderIds,
revealedObjectIds,
workspaceNav,
} = useAppPreviewState(visual);
let displayPage = activePage;
if (
activePage !== null &&
activePage !== undefined &&
activePage.type === 'table'
) {
const title = activePage.header?.title;
if (companyAdded && title === 'All Companies') {
displayPage = {
...activePage,
header: {
...activePage.header,
count: (activePage.header.count ?? 0) + 1,
},
rows: [NEW_COMPANY_ROW, ...activePage.rows],
};
} else if (personAdded && title === 'All People') {
displayPage = {
...activePage,
header: {
...activePage.header,
count: (activePage.header.count ?? 0) + 1,
},
rows: [NEW_PERSON_ROW, ...activePage.rows],
};
}
}
useEffect(() => {
const option = PROMPT_OPTIONS[selectedOption];
const fullText = option.response;
let index = 0;
const completedSteps = new Set<number>();
let companyInjected = false;
let personInjected = false;
setStreamedText('');
setStreamComplete(false);
setCompanyAdded(false);
setPersonAdded(false);
const interval = setInterval(() => {
index += 1;
setStreamedText(fullText.slice(0, index));
const progress = index / fullText.length;
option.navSteps.forEach((step, stepIndex) => {
if (!completedSteps.has(stepIndex) && progress >= step.at) {
completedSteps.add(stepIndex);
handleSelectLabel(step.target);
}
});
if (selectedOption === 0) {
if (!companyInjected && progress >= 0.2) {
companyInjected = true;
setCompanyAdded(true);
}
if (!personInjected && progress >= 0.6) {
personInjected = true;
setPersonAdded(true);
}
}
if (index >= fullText.length) {
clearInterval(interval);
setStreamComplete(true);
}
}, 20);
return () => clearInterval(interval);
}, [selectedOption, handleSelectLabel]);
const handleOptionSelect = useCallback(
(optionIndex: number) => setSelectedOption(optionIndex),
[],
);
return {
activeItem,
activeLabel,
displayPage,
handleOptionSelect,
handleSelectLabel,
handleToggleFolder,
highlightedItemId,
openFolderIds,
revealedObjectIds,
selectedOption,
streamComplete,
streamedText,
workspaceNav,
};
}

View File

@@ -3,7 +3,7 @@ import { Cta } from './Cta';
import { Heading } from './Heading';
import { AppPreview } from '@/sections/AppPreview';
import { PartnerVisual } from './PartnerVisual/PartnerVisual';
import { ProductVisual } from './ProductVisual';
import { ProductVisual } from './ProductVisual/ProductVisual';
import { ReleaseNotesVisual } from './ReleaseNotesVisual';
import { Root } from './Root';
import { WhyTwentyVisual } from './WhyTwentyVisual';