[Website] Change product hero to reveal tabs on scroll. (#20707)

Here's the video - it still needs refinement, but we want to hide
articles and product pages to push out a release today. Merging this as
a checkpoint so the next PR can hide these pages from navigation for the
release.


https://github.com/user-attachments/assets/eb5048b2-d3df-4920-a62a-5b2617d11e4a
This commit is contained in:
Abdullah.
2026-05-19 15:12:44 +05:00
committed by GitHub
parent fecea1bfae
commit f9e3683518
29 changed files with 1977 additions and 314 deletions
@@ -1372,6 +1372,79 @@ export const APP_PREVIEW_DATA: { visual: AppPreviewConfig } = {
},
},
},
{
id: 'workflow-send-email-sequence',
label: 'Send email sequence when deal is engaged',
icon: {
color: '#451E11',
kind: 'avatar',
label: 'S',
tone: 'orange',
shape: 'circle',
},
page: {
type: 'workflow',
header: {
navbarActions: [
{ icon: 'chevronDown', variant: 'icon' },
{ icon: 'chevronUp', variant: 'icon' },
{ icon: 'heart', variant: 'icon' },
{ icon: 'playerPause', label: 'Deactivate' },
{ icon: 'repeat', label: 'See Runs' },
{ icon: 'plus', label: 'Add a Node' },
{ icon: 'dotsVertical', trailingLabel: '⌘K' },
],
title: 'Send email sequence when deal is engaged',
},
nodes: [
{
id: 'trigger',
x: 415,
y: 60,
width: 200,
label: 'Trigger',
title: 'Manual trigger',
iconName: 'plug',
},
{
id: 'iterator',
x: 420,
y: 230,
width: 190,
label: 'Action',
title: 'Iterator',
iconName: 'repeat',
},
{
id: 'send-email',
x: 590,
y: 340,
width: 200,
label: 'Action',
title: 'Send Email',
iconName: 'mail',
},
],
edges: [
{ from: 'trigger', to: 'iterator', type: 'vertical' },
{
from: 'iterator',
to: 'send-email',
type: 'loopRight',
},
{
from: 'send-email',
to: 'trigger',
type: 'loopBack',
},
],
branchLabels: [
{ x: 635, y: 248, text: 'loop' },
{ x: 482, y: 318, text: 'completed' },
],
plusNode: { x: 515, y: 380 },
},
},
{
id: 'workflow-list',
label: 'All Workflows',
@@ -0,0 +1,22 @@
import { msg } from '@lingui/core/macro';
import type { TabType } from '@/sections/Tabs';
export const AI_HERO_TABS: TabType[] = [
{
body: msg`Show me all deals closing this month`,
icon: 'search',
},
{
body: msg`Create follow-up tasks for my top 10 accounts`,
icon: 'check',
},
{
body: msg`Summarize this customer's history`,
icon: 'edit',
},
{
body: msg`Create a workflow that sends an email sequence`,
icon: 'code',
},
];
@@ -1,45 +1,43 @@
import { msg } from '@lingui/core/macro';
import type { AppLocale } from 'twenty-shared/translations';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { TalkToUsButton } from '@/sections/ContactCal';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import { AI_HERO_TABS } from '@/app/[locale]/product/ai-hero-tabs.data';
import { FEATURE_TILES } from '@/app/[locale]/product/feature.data';
import { TABS } from '@/app/[locale]/product/tabs.data';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data';
import {
Body,
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Feature } from '@/sections/Feature';
import { Hero } from '@/sections/Hero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Demo } from '@/sections/Demo';
import {
ProductStepper,
type ProductStepperStepType,
} from '@/sections/ProductStepper';
import { DataModelVisual } from '@/sections/ProductStepper/visuals/DataModelVisual';
import { WorkflowVisual } from '@/sections/ProductStepper/visuals/WorkflowVisual';
import { LayoutVisual } from '@/sections/ProductStepper/visuals/LayoutVisual';
import { Tabs } from '@/sections/Tabs';
import { ThreeCards } from '@/sections/ThreeCards';
import { theme } from '@/theme';
import {
buildBreadcrumbListJsonLd,
buildRouteMetadata,
JsonLd,
} from '@/lib/seo';
import { TalkToUsButton } from '@/sections/ContactCal';
import { Demo } from '@/sections/Demo';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { Feature } from '@/sections/Feature';
import { Hero } from '@/sections/Hero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import {
ProductStepper,
type ProductStepperStepType,
} from '@/sections/ProductStepper';
import { DataModelVisual } from '@/sections/ProductStepper/visuals/DataModelVisual';
import { LayoutVisual } from '@/sections/ProductStepper/visuals/LayoutVisual';
import { WorkflowVisual } from '@/sections/ProductStepper/visuals/WorkflowVisual';
import { ThreeCards } from '@/sections/ThreeCards';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { theme } from '@/theme';
import { msg } from '@lingui/core/macro';
import type { AppLocale } from 'twenty-shared/translations';
export const generateMetadata = buildRouteMetadata('product');
@@ -104,30 +102,46 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root scheme="light">
<Hero.Heading page={Pages.Product}>
<HeadingPart fontFamily="serif">
{i18n._(msg`A CRM for teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">{i18n._(msg`that`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`moves fast`)}</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Product}>
{i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
</Hero.Body>
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
</Hero.Cta>
<Hero.ProductVisual visual={APP_PREVIEW_DATA.visual} />
</Hero.Root>
<Hero.HeroVisualScroll
aiBody={i18n._(
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
)}
aiHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`AI that actually`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`helps you`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`work faster`)}
</HeadingPart>
</Heading>
}
ctaHref="https://app.twenty.com/welcome"
ctaLabel={i18n._(msg`Get started`)}
introBody={i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
introHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A CRM for teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`that`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`move fast`)}
</HeadingPart>
</Heading>
}
tabs={AI_HERO_TABS}
visual={APP_PREVIEW_DATA.visual}
/>
<TrustedBy.Root
separator={i18n._(msg`trusted by`)}
@@ -135,30 +149,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
clientCount={i18n._(msg`+10k others`)}
/>
<Tabs.Root>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`AI & Automation`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`AI that actually`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`helps you`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`work faster`)}
</HeadingPart>
</Heading>
<Body size="sm">
{i18n._(msg`The AI understands your CRM and takes action.`)}
</Body>
<Tabs.TabGroup tabs={TABS} />
</Tabs.Root>
<Feature.Root scheme="light">
<Feature.Intro align="center" page={Pages.Product}>
<Eyebrow>
@@ -1,38 +0,0 @@
import { msg } from '@lingui/core/macro';
import type { TabType } from '@/sections/Tabs';
export const TABS: TabType[] = [
{
body: msg`Show me all deals closing this month`,
icon: 'search',
image: {
src: '/images/product/tabs/deals.webp',
alt: 'Deals view',
},
},
{
body: msg`Create follow-up tasks for my top 10 accounts`,
icon: 'eye',
image: {
src: '/images/product/tabs/tasks.webp',
alt: 'Tasks view',
},
},
{
body: msg`Summarize this customer's history`,
icon: 'edit',
image: {
src: '/images/product/tabs/history.webp',
alt: 'History view',
},
},
{
body: msg`Create a workflow that send an email sequence`,
icon: 'check',
image: {
src: '/images/product/tabs/workflow.webp',
alt: 'Workflow view',
},
},
];
@@ -103,6 +103,7 @@ export function AppPreview({ showTerminal = true, visual }: AppPreviewProps) {
activePage !== null &&
activePage !== undefined &&
activePage.type !== 'dashboard' &&
activePage.type !== 'record' &&
activePage.type !== 'workflow';
useEffect(
@@ -1,13 +1,7 @@
'use client';
import { styled } from '@linaria/react';
import {
useCallback,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { theme } from '@/theme';
import { VISUAL_TOKENS } from '../Shared/utils/app-preview-tokens';
import { useWindowPointerInteractions } from '../WindowInteraction/use-window-pointer-interactions';
@@ -130,13 +124,14 @@ type AppWindowProps = {
export const AppWindow = ({ children }: AppWindowProps) => {
const shellRef = useRef<HTMLDivElement>(null);
const interactingRef = useRef(false);
const [position, setPosition] = useState<Position | null>(null);
const [size, setSize] = useState<Size | null>(null);
const { activate, zIndex } = useWindowOrder(WINDOW_ID);
useLayoutEffect(() => {
const recalcLayout = () => {
const shell = shellRef.current;
const parent = shell?.parentElement as HTMLElement | null;
if (!parent) {
@@ -145,33 +140,52 @@ export const AppWindow = ({ children }: AppWindowProps) => {
const parentRect = parent.getBoundingClientRect();
if (parentRect.width < MOBILE_PARENT_BREAKPOINT) {
const mobileWidth = Math.min(parentRect.width, 320);
const mobileWidth = parentRect.width;
const mobileHeight = Math.min(
parentRect.height,
mobileWidth / INITIAL_ASPECT_RATIO + 100,
mobileWidth / INITIAL_ASPECT_RATIO,
);
setSize({ width: mobileWidth, height: mobileHeight });
setPosition({ left: 0, top: 0 });
return;
}
const initialWidth = Math.min(parentRect.width, INITIAL_MAX_WIDTH);
const initialHeight = Math.min(
const newWidth = Math.min(parentRect.width, INITIAL_MAX_WIDTH);
const newHeight = Math.min(
parentRect.height,
initialWidth / INITIAL_ASPECT_RATIO,
newWidth / INITIAL_ASPECT_RATIO,
);
setSize({ width: initialWidth, height: initialHeight });
setSize({ width: newWidth, height: newHeight });
setPosition({
left: Math.max(0, (parentRect.width - initialWidth) / 2),
left: Math.max(0, (parentRect.width - newWidth) / 2),
top: MIN_EDGE_GAP,
});
};
useLayoutEffect(() => {
recalcLayout();
const parent = shellRef.current?.parentElement as HTMLElement | null;
if (!parent) {
return;
}
const observer = new ResizeObserver(() => {
if (!interactingRef.current) {
recalcLayout();
}
});
observer.observe(parent);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getParentRect = useCallback(() => {
const getParentRect = () => {
const parent = shellRef.current?.parentElement as HTMLElement | null;
return parent?.getBoundingClientRect() ?? null;
}, []);
};
const {
handleDragStart,
@@ -194,13 +208,10 @@ export const AppWindow = ({ children }: AppWindowProps) => {
size,
});
const handleShellPointerDown = useCallback(() => {
activate();
}, [activate]);
const isReady = position !== null && size !== null;
const isInteracting = isDragging || isResizing;
interactingRef.current = isInteracting;
const renderPosition = isInteracting
? (latestPositionRef.current ?? position)
: position;
@@ -211,7 +222,7 @@ export const AppWindow = ({ children }: AppWindowProps) => {
$isActive={isReady && zIndex > 2}
$isReady={isReady}
$isResizing={isResizing}
onPointerDown={handleShellPointerDown}
onPointerDown={activate}
ref={shellRef}
style={{
height: renderSize ? `${renderSize.height}px` : undefined,
@@ -11,13 +11,26 @@ import {
KANBAN_PAGE_TABLER_STROKE,
} from './kanban-page-theme';
const Lane = styled.div<{ $last?: boolean }>`
const Lane = styled.div<{ $index: number; $last?: boolean }>`
animation: kanbanLaneAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${120 + $index * 80}ms`};
border-right: ${({ $last }) =>
$last ? 'none' : `1px solid ${KANBAN_PAGE_COLORS.borderLight}`};
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
@keyframes kanbanLaneAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const LaneHeader = styled.div`
@@ -77,15 +90,17 @@ const AddCardButton = styled.div`
export function KanbanLane({
lane,
index = 0,
isLast,
}: {
index?: number;
isLast: boolean;
lane: KanbanLane;
}) {
const tone = KANBAN_LANE_TONES[lane.tone] ?? KANBAN_LANE_TONES.gray;
return (
<Lane $last={isLast}>
<Lane $index={index} $last={isLast}>
<LaneHeader>
<LaneTag $background={tone.background} $color={tone.color}>
{lane.label}
@@ -41,6 +41,7 @@ export function KanbanPage({ page }: { page: KanbanPageDefinition }) {
{page.lanes.map((lane, index) => (
<KanbanLane
key={lane.id}
index={index}
isLast={index === page.lanes.length - 1}
lane={lane}
/>
@@ -0,0 +1,581 @@
'use client';
import { styled } from '@linaria/react';
import {
IconCalendar,
IconCheckbox,
IconChevronDown,
IconFile,
IconMail,
IconNotes,
IconTimeline,
} from '@tabler/icons-react';
import type { RecordField, RecordPageDefinition } from '../../types';
import { FaviconLogo } from '../../Shared/components/FaviconLogo';
import {
APP_FONT,
COLORS,
TABLER_STROKE,
} from '../../Shared/utils/app-preview-theme';
import { VISUAL_TOKENS } from '../../Shared/utils/app-preview-tokens';
const Shell = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
width: 100%;
`;
const LeftPanel = styled.div`
border-right: 1px solid ${COLORS.borderLight};
display: flex;
flex: 0 0 220px;
flex-direction: column;
gap: 12px;
overflow-y: auto;
padding: 16px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const RecordHeader = styled.div`
align-items: center;
animation: recordHeaderAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 120ms;
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 4px;
@keyframes recordHeaderAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const RecordName = styled.div`
font-family: ${APP_FONT};
font-size: 14px;
font-weight: 500;
color: ${COLORS.text};
`;
const RecordMeta = styled.div`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 11px;
`;
const FieldList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const FieldRow = styled.div<{ $index: number }>`
align-items: center;
animation: fieldRowAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${190 + $index * 70}ms`};
display: grid;
gap: 8px;
grid-template-columns: 14px 70px 1fr;
min-height: 22px;
@keyframes fieldRowAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const FieldIcon = styled.span`
align-items: center;
color: ${COLORS.textTertiary};
display: flex;
justify-content: center;
`;
const FieldLabel = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const FieldValue = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const FieldValueChip = styled.span`
background-color: ${VISUAL_TOKENS.background.transparent.lighter};
border: 1px solid ${VISUAL_TOKENS.border.color.strong};
border-radius: ${VISUAL_TOKENS.border.radius.pill};
color: ${COLORS.text};
display: inline-block;
font-family: ${APP_FONT};
font-size: 11px;
line-height: 1;
overflow: hidden;
padding: 3px 8px;
text-overflow: ellipsis;
white-space: nowrap;
width: fit-content;
`;
const FieldValuePerson = styled.span`
align-items: center;
color: ${COLORS.text};
display: flex;
font-family: ${APP_FONT};
font-size: 11px;
gap: 4px;
`;
const MoreToggle = styled.div`
align-items: center;
color: ${COLORS.textTertiary};
cursor: default;
display: flex;
font-family: ${APP_FONT};
font-size: 11px;
gap: 4px;
padding: 2px 0;
`;
const Divider = styled.div`
border-top: 1px solid ${COLORS.borderLight};
margin: 4px 0;
`;
const RelationSection = styled.div`
animation: relationAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 600ms;
display: flex;
flex-direction: column;
gap: 6px;
@keyframes relationAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const RelationTitle = styled.div`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 11px;
font-weight: 500;
`;
const RelationTitleCount = styled.span`
color: ${COLORS.textTertiary};
font-weight: 400;
margin-left: 4px;
`;
const RelationItem = styled.div`
align-items: center;
display: flex;
gap: 6px;
padding: 2px 0;
`;
const RelationName = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const PersonAvatar = styled.img`
border-radius: 50%;
flex: 0 0 auto;
height: 16px;
object-fit: cover;
width: 16px;
`;
const CenterPanel = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
overflow: hidden;
`;
const TabBar = styled.div`
align-items: center;
animation: tabBarAppear 260ms ease-out both;
animation-delay: 120ms;
border-bottom: 1px solid ${COLORS.borderLight};
display: flex;
flex: 0 0 auto;
gap: 0;
padding: 0 16px;
@keyframes tabBarAppear {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const Tab = styled.div<{ $active?: boolean }>`
align-items: center;
border-bottom: ${({ $active }) =>
$active ? `2px solid ${COLORS.text}` : '2px solid transparent'};
color: ${({ $active }) => ($active ? COLORS.text : COLORS.textTertiary)};
cursor: default;
display: flex;
font-family: ${APP_FONT};
font-size: 11px;
font-weight: ${({ $active }) => ($active ? '500' : '400')};
gap: 4px;
padding: 8px 10px;
`;
const NotesHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding: 12px 16px 8px;
`;
const NotesCount = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 12px;
font-weight: 500;
`;
const AddNoteButton = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 11px;
`;
const NotesGrid = styled.div`
display: grid;
gap: 10px;
grid-template-columns: repeat(2, 1fr);
overflow-y: auto;
padding: 0 16px 16px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const NoteCard = styled.div<{ $index: number }>`
animation: noteCardAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${400 + $index * 100}ms`};
border: 1px solid ${COLORS.borderLight};
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
@keyframes noteCardAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const NoteTitle = styled.div`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 12px;
font-weight: 500;
`;
const NoteBody = styled.div`
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
color: ${COLORS.textSecondary};
display: -webkit-box;
font-family: ${APP_FONT};
font-size: 11px;
line-height: 1.5;
overflow: hidden;
`;
const NoteRelation = styled.div`
align-items: center;
display: flex;
gap: 5px;
margin-top: 4px;
`;
const NoteRelationArrow = styled.svg`
flex-shrink: 0;
height: 10px;
width: 10px;
`;
const NoteRelationLabel = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 10px;
line-height: 14px;
`;
const NoteRelationName = styled.span`
color: ${COLORS.text};
font-family: ${APP_FONT};
font-size: 10px;
line-height: 14px;
`;
const NoteRelationAvatar = styled.img`
border-radius: 50%;
height: 14px;
object-fit: cover;
width: 14px;
`;
const RECORD_TABS = [
{ label: 'Timeline', Icon: IconTimeline },
{ label: 'Tasks', Icon: IconCheckbox },
{ label: 'Notes', Icon: IconNotes, active: true },
{ label: 'Files', Icon: IconFile },
{ label: 'Emails', Icon: IconMail },
{ label: 'Calendar', Icon: IconCalendar },
];
function FieldValueRenderer({ field }: { field: RecordField }) {
if (field.avatarUrl) {
return (
<FieldValuePerson>
<PersonAvatar
alt={field.value}
src={field.avatarUrl}
style={{ width: 14, height: 14 }}
/>
{field.value}
</FieldValuePerson>
);
}
if (field.value.includes('.com') || field.value.startsWith('@')) {
return <FieldValueChip>{field.value}</FieldValueChip>;
}
return <FieldValue>{field.value}</FieldValue>;
}
export function RecordPage({ page }: { page: RecordPageDefinition }) {
const { record, notes } = page;
return (
<Shell>
<LeftPanel>
<RecordHeader>
<FaviconLogo
domain={record.logoDomain}
label={record.name}
size={48}
/>
<RecordName>{record.name}</RecordName>
<RecordMeta>{record.createdAt}</RecordMeta>
</RecordHeader>
<FieldList>
{record.fields.map((field, index) => (
<FieldRow $index={index} key={field.label}>
<FieldIcon>
{field.icon ? (
<svg
fill="none"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="12"
>
<FieldIconPath iconName={field.icon} />
</svg>
) : null}
</FieldIcon>
<FieldLabel>{field.label}</FieldLabel>
<FieldValueRenderer field={field} />
</FieldRow>
))}
</FieldList>
{record.moreCount ? (
<MoreToggle>
<IconChevronDown size={12} stroke={TABLER_STROKE} />
More ({record.moreCount})
</MoreToggle>
) : null}
<Divider />
{record.relations.map((section) => (
<RelationSection key={section.title}>
<RelationTitle>
{section.title}
{section.count ? (
<RelationTitleCount>All ({section.count})</RelationTitleCount>
) : null}
</RelationTitle>
{section.items.map((item) => (
<RelationItem key={item.name}>
{item.avatarUrl ? (
<PersonAvatar alt={item.name} src={item.avatarUrl} />
) : item.domain ? (
<FaviconLogo
domain={item.domain}
label={item.name}
size={16}
/>
) : (
<FaviconLogo label={item.name} size={16} />
)}
<RelationName>{item.name}</RelationName>
</RelationItem>
))}
</RelationSection>
))}
</LeftPanel>
<CenterPanel>
<TabBar>
{RECORD_TABS.map((tab) => (
<Tab key={tab.label} $active={tab.active}>
<tab.Icon size={12} stroke={TABLER_STROKE} />
{tab.label}
</Tab>
))}
</TabBar>
<NotesHeader>
<NotesCount>All {notes.length}</NotesCount>
<AddNoteButton>+ Add note</AddNoteButton>
</NotesHeader>
<NotesGrid>
{notes.map((note, index) => (
<NoteCard $index={index} key={note.id}>
<NoteTitle>{note.title}</NoteTitle>
<NoteBody>{note.body}</NoteBody>
{note.relation ? (
<NoteRelation>
<NoteRelationArrow
fill="none"
stroke={COLORS.textTertiary}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
viewBox="0 0 12 12"
>
<path d="M3.5 8.5L8.5 3.5M8.5 3.5H4.5M8.5 3.5V7.5" />
</NoteRelationArrow>
<NoteRelationLabel>Relations:</NoteRelationLabel>
{note.relation.avatarUrl ? (
<NoteRelationAvatar
alt={note.relation.name}
src={note.relation.avatarUrl}
/>
) : null}
<NoteRelationName>{note.relation.name}</NoteRelationName>
</NoteRelation>
) : null}
</NoteCard>
))}
</NotesGrid>
</CenterPanel>
</Shell>
);
}
function FieldIconPath({ iconName }: { iconName: string }) {
switch (iconName) {
case 'link':
return (
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
);
case 'user':
return (
<>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</>
);
case 'mapPin':
return (
<>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</>
);
case 'check':
return <path d="M20 6 9 17l-5-5" />;
case 'currency':
return (
<>
<line x1="12" x2="12" y1="2" y2="22" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</>
);
case 'linkedin':
return (
<>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect height="12" width="4" x="2" y="9" />
<circle cx="4" cy="4" r="2" />
</>
);
case 'twitter':
return (
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
);
default:
return <circle cx="12" cy="12" r="1" />;
}
}
@@ -8,8 +8,10 @@ import { getWorkflowEdgePath } from './workflow-page-geometry';
import {
WORKFLOW_CANVAS_HEIGHT,
WORKFLOW_CANVAS_WIDTH,
WORKFLOW_NODE_HEIGHT,
WORKFLOW_PAGE_COLORS,
} from './workflow-page-theme';
import { getWorkflowNodeById } from './get-workflow-node-by-id';
const CanvasOverlay = styled.svg`
inset: 0;
@@ -21,10 +23,17 @@ const CanvasOverlay = styled.svg`
export function WorkflowEdges({
edges,
nodes,
plusNode,
}: {
edges: ReadonlyArray<WorkflowEdgeDefinition>;
nodes: ReadonlyArray<WorkflowNodeDefinition>;
plusNode?: { x: number; y: number };
}) {
const completedEdge = edges.find((e) => e.type === 'loopRight');
const completedSourceNode = completedEdge
? getWorkflowNodeById(nodes, completedEdge.from)
: undefined;
return (
<CanvasOverlay
aria-hidden
@@ -51,16 +60,63 @@ export function WorkflowEdges({
</defs>
{edges.map((edge) => (
<path
key={`${edge.from}-${edge.to}`}
key={`${edge.from}-${edge.to}-${edge.type}`}
d={getWorkflowEdgePath({ edge, nodes })}
fill="none"
markerEnd="url(#workflow-arrow)"
markerEnd={
edge.type === 'loopBack' ? undefined : 'url(#workflow-arrow)'
}
stroke={WORKFLOW_PAGE_COLORS.arrowStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1"
/>
))}
{plusNode && completedSourceNode && (
<path
d={`M${completedSourceNode.x + completedSourceNode.width / 2} ${completedSourceNode.y + WORKFLOW_NODE_HEIGHT + 1} L${plusNode.x} ${plusNode.y}`}
fill="none"
stroke={WORKFLOW_PAGE_COLORS.arrowStroke}
strokeDasharray="4 3"
strokeLinecap="round"
strokeWidth="1"
/>
)}
{nodes.map((node) => {
const cx = node.x + node.width / 2;
const bottomY = node.y + WORKFLOW_NODE_HEIGHT + 1;
const rightX = node.x + node.width;
const midY = node.y + WORKFLOW_NODE_HEIGHT / 2;
return (
<g key={`ports-${node.id}`}>
<circle
cx={cx}
cy={node.y}
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
r="4"
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
strokeWidth="1"
/>
<circle
cx={cx}
cy={bottomY}
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
r="4"
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
strokeWidth="1"
/>
<circle
cx={rightX}
cy={midY}
fill={WORKFLOW_PAGE_COLORS.nodeSurface}
r="4"
stroke={WORKFLOW_PAGE_COLORS.nodeBorder}
strokeWidth="1"
/>
</g>
);
})}
</CanvasOverlay>
);
}
@@ -9,8 +9,10 @@ import {
WORKFLOW_PAGE_TABLER_STROKE,
} from './workflow-page-theme';
const Node = styled.div`
const Node = styled.div<{ $index: number }>`
align-items: center;
animation: workflowNodeAppear 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: ${({ $index }) => `${150 + $index * 120}ms`};
background: ${WORKFLOW_PAGE_COLORS.nodeSurface};
border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder};
border-radius: 8px;
@@ -23,6 +25,17 @@ const Node = styled.div`
position: absolute;
top: 0;
z-index: 1;
@keyframes workflowNodeAppear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const NodeIconContainer = styled.div`
@@ -68,16 +81,17 @@ const NodeTitle = styled.div`
`;
export function WorkflowNode({
x,
y,
width,
label,
title,
Icon,
iconColor,
}: WorkflowNodeDefinition) {
index = 0,
label,
title,
width,
x,
y,
}: Omit<WorkflowNodeDefinition, 'id'> & { index?: number }) {
return (
<Node style={{ left: x, top: y, width }}>
<Node $index={index} style={{ left: x, top: y, width }}>
<NodeIconContainer>
<Icon
aria-hidden
@@ -1,16 +1,18 @@
'use client';
import type { WorkflowPageDefinition } from '../../types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
import type { WorkflowPageDefinition } from '../../types';
import { resolveWorkflowNodes } from './resolve-workflow-nodes';
import { WorkflowBranchLabel } from './WorkflowBranchLabel';
import { WorkflowEdges } from './WorkflowEdges';
import { WorkflowNode } from './WorkflowNode';
import {
workflowBranchLabels,
workflowEdges,
workflowNodes,
workflowBranchLabels as defaultBranchLabels,
workflowEdges as defaultEdges,
workflowNodes as defaultNodes,
} from './workflow-page-data';
import {
WORKFLOW_CANVAS_HEIGHT,
@@ -94,7 +96,32 @@ const ActiveBadgeLabel = styled.span`
padding: 0 8px;
`;
const PLUS_NODE_SIZE = 24;
const PlusNodeSquare = styled.div`
align-items: center;
background: ${WORKFLOW_PAGE_COLORS.nodeSurface};
border: 1px solid ${WORKFLOW_PAGE_COLORS.nodeBorder};
border-radius: 4px;
color: ${WORKFLOW_PAGE_COLORS.textTertiary};
display: flex;
font-size: 16px;
font-weight: 300;
height: ${PLUS_NODE_SIZE}px;
justify-content: center;
position: absolute;
width: ${PLUS_NODE_SIZE}px;
`;
export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
const hasCustomNodes = Boolean(page.nodes);
const nodes = hasCustomNodes
? resolveWorkflowNodes(page.nodes!)
: defaultNodes;
const edges = page.edges ?? (hasCustomNodes ? [] : defaultEdges);
const branchLabels =
page.branchLabels ?? (hasCustomNodes ? [] : defaultBranchLabels);
return (
<PageShell>
<CanvasViewportShell>
@@ -106,14 +133,18 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
>
<Canvas>
<CanvasContent>
<WorkflowEdges edges={workflowEdges} nodes={workflowNodes} />
<WorkflowEdges
edges={edges}
nodes={nodes}
plusNode={page.plusNode}
/>
{workflowNodes.map((node) => (
{nodes.map((node, index) => (
<WorkflowNode
key={`${node.title}-${node.x}-${node.y}`}
Icon={node.Icon}
iconColor={node.iconColor}
id={node.id}
index={index}
label={node.label}
title={node.title}
width={node.width}
@@ -122,7 +153,7 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
/>
))}
{workflowBranchLabels.map((label) => (
{branchLabels.map((label) => (
<WorkflowBranchLabel
key={`${label.text}-${label.x}-${label.y}`}
text={label.text}
@@ -130,6 +161,17 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
y={label.y}
/>
))}
{page.plusNode && (
<PlusNodeSquare
style={{
left: page.plusNode.x - PLUS_NODE_SIZE / 2,
top: page.plusNode.y - PLUS_NODE_SIZE / 2,
}}
>
+
</PlusNodeSquare>
)}
</CanvasContent>
</Canvas>
</CanvasViewport>
@@ -0,0 +1,42 @@
import {
IconClock,
IconCode,
IconFilter,
IconMail,
IconPlug,
IconPlus,
IconRepeat,
IconSearch,
IconSitemap,
} from '@tabler/icons-react';
import type { WorkflowNodeDef } from '../../types';
import type { WorkflowNodeDefinition } from './workflow-page-data';
import { WORKFLOW_PAGE_COLORS } from './workflow-page-theme';
const ICON_MAP: Record<string, typeof IconPlug> = {
clock: IconClock,
code: IconCode,
filter: IconFilter,
mail: IconMail,
plug: IconPlug,
plus: IconPlus,
repeat: IconRepeat,
search: IconSearch,
sitemap: IconSitemap,
};
export function resolveWorkflowNodes(
defs: WorkflowNodeDef[],
): WorkflowNodeDefinition[] {
return defs.map((def) => ({
id: def.id,
x: def.x,
y: def.y,
width: def.width,
label: def.label,
title: def.title,
Icon: ICON_MAP[def.iconName] ?? IconCode,
iconColor: def.iconColor ?? WORKFLOW_PAGE_COLORS.nodeActionIcon,
}));
}
@@ -30,7 +30,13 @@ export type WorkflowBranchLabel = {
export type WorkflowEdgeDefinition = {
from: string;
to: string;
type: 'branch' | 'curve' | 'vertical';
type:
| 'branch'
| 'curve'
| 'loopBack'
| 'loopRight'
| 'smoothStep'
| 'vertical';
};
export const workflowNodes: WorkflowNodeDefinition[] = [
@@ -10,6 +10,8 @@ type Point = {
y: number;
};
const CORNER_RADIUS = 8;
function getNodeTopCenter(node: WorkflowNodeDefinition): Point {
return {
x: node.x + node.width / 2,
@@ -24,6 +26,76 @@ function getNodeBottomCenter(node: WorkflowNodeDefinition): Point {
};
}
function getNodeRightCenter(node: WorkflowNodeDefinition): Point {
return {
x: node.x + node.width,
y: node.y + WORKFLOW_NODE_HEIGHT / 2,
};
}
function getRightDownPath(
fromNode: WorkflowNodeDefinition,
toNode: WorkflowNodeDefinition,
): string {
const start = getNodeRightCenter(fromNode);
const end = getNodeTopCenter(toNode);
const r = CORNER_RADIUS;
return [
`M${start.x} ${start.y}`,
`L${end.x - r} ${start.y}`,
`A${r} ${r} 0 0 1 ${end.x} ${start.y + r}`,
`L${end.x} ${end.y}`,
].join(' ');
}
function getLoopBackPath(
fromNode: WorkflowNodeDefinition,
toNode: WorkflowNodeDefinition,
): string {
const r = CORNER_RADIUS;
const padRight = 40;
const padBottom = 180;
const start = getNodeBottomCenter(fromNode);
const endX = toNode.x + toNode.width / 2;
const endY = toNode.y + WORKFLOW_NODE_HEIGHT + 50;
const rightX = fromNode.x + fromNode.width + padRight;
const bottomY = start.y + padBottom;
return [
`M${start.x} ${start.y}`,
`L${start.x} ${bottomY - r}`,
`A${r} ${r} 0 0 0 ${start.x + r} ${bottomY}`,
`L${rightX - r} ${bottomY}`,
`A${r} ${r} 0 0 0 ${rightX} ${bottomY - r}`,
`L${rightX} ${endY + r}`,
`A${r} ${r} 0 0 0 ${rightX - r} ${endY}`,
`L${endX} ${endY}`,
].join(' ');
}
function getSmoothStepPath(start: Point, end: Point): string {
const r = CORNER_RADIUS;
const midY = start.y + (end.y - start.y) * 0.4;
if (Math.abs(start.x - end.x) < 2) {
return `M${start.x} ${start.y} L${end.x} ${end.y}`;
}
const goingRight = end.x > start.x;
return [
`M${start.x} ${start.y}`,
`L${start.x} ${midY - r}`,
`A${r} ${r} 0 0 ${goingRight ? 0 : 1} ${start.x + (goingRight ? r : -r)} ${midY}`,
`L${end.x - (goingRight ? r : -r)} ${midY}`,
`A${r} ${r} 0 0 ${goingRight ? 1 : 0} ${end.x} ${midY + r}`,
`L${end.x} ${end.y}`,
].join(' ');
}
export function getWorkflowEdgePath({
edge,
nodes,
@@ -33,6 +105,15 @@ export function getWorkflowEdgePath({
}) {
const fromNode = getWorkflowNodeById(nodes, edge.from);
const toNode = getWorkflowNodeById(nodes, edge.to);
if (edge.type === 'loopRight') {
return getRightDownPath(fromNode, toNode);
}
if (edge.type === 'loopBack') {
return getLoopBackPath(fromNode, toNode);
}
const start = getNodeBottomCenter(fromNode);
const end = getNodeTopCenter(toNode);
@@ -40,6 +121,10 @@ export function getWorkflowEdgePath({
return `M${start.x} ${start.y} L${end.x} ${end.y}`;
}
if (edge.type === 'smoothStep') {
return getSmoothStepPath(start, end);
}
const controlStartY = start.y + 28;
const controlEndY = end.y - 28;
@@ -9,11 +9,14 @@ import type {
KanbanPageDefinition,
PageDefinition,
PageType,
RecordPageDefinition,
TablePageDefinition,
WorkflowPageDefinition,
} from '../types';
import { KanbanPage } from '../Pages/Kanban/KanbanPage';
import { RecordPage } from '../Pages/Record/RecordPage';
import { TablePage } from '../Pages/Table/TablePage';
import { WorkflowPage } from '../Pages/Workflow/WorkflowPage';
import { PagePreviewLoader } from '../Shared/components/PagePreviewLoader';
const DashboardViewport = styled.div`
@@ -32,8 +35,6 @@ const DashboardViewport = styled.div`
const loadSalesDashboardPageModule = () =>
import('../Pages/Dashboard/SalesDashboardPage');
const loadWorkflowPageModule = () => import('../Pages/Workflow/WorkflowPage');
const SalesDashboardPage = dynamic(
() =>
loadSalesDashboardPageModule().then((mod) => ({
@@ -47,17 +48,6 @@ const SalesDashboardPage = dynamic(
},
);
const WorkflowPage = dynamic(
() =>
loadWorkflowPageModule().then((mod) => ({
default: mod.WorkflowPage,
})),
{
loading: () => <PagePreviewLoader ariaLabel="Loading workflow preview" />,
ssr: false,
},
);
const PAGE_RENDERERS = {
table: (page: TablePageDefinition) => <TablePage page={page} />,
kanban: (page: KanbanPageDefinition) => <KanbanPage page={page} />,
@@ -68,6 +58,7 @@ const PAGE_RENDERERS = {
<SalesDashboardPage page={page} />
</DashboardViewport>
),
record: (page: RecordPageDefinition) => <RecordPage page={page} />,
workflow: (page: WorkflowPageDefinition) => <WorkflowPage page={page} />,
} satisfies {
[K in PageType]: (page: Extract<PageDefinition, { type: K }>) => ReactNode;
@@ -91,6 +82,8 @@ export function renderPageDefinition(
return PAGE_RENDERERS.kanban(page);
case 'dashboard':
return PAGE_RENDERERS.dashboard(page);
case 'record':
return PAGE_RENDERERS.record(page);
case 'workflow':
return PAGE_RENDERERS.workflow(page);
}
@@ -1,15 +1,10 @@
const loadSalesDashboardPageModule = () =>
import('../Pages/Dashboard/SalesDashboardPage');
const loadWorkflowPageModule = () => import('../Pages/Workflow/WorkflowPage');
let deferredPagePreloadPromise: Promise<void> | null = null;
export function preloadDeferredPages() {
deferredPagePreloadPromise ??= Promise.all([
loadSalesDashboardPageModule(),
loadWorkflowPageModule(),
])
deferredPagePreloadPromise ??= loadSalesDashboardPageModule()
.then(() => undefined)
.catch((error: unknown) => {
deferredPagePreloadPromise = null;
@@ -8,5 +8,6 @@ export type {
DashboardData,
DashboardPageDefinition,
KanbanPageDefinition,
RecordPageDefinition,
TablePageDefinition,
} from './types';
@@ -104,8 +104,41 @@ export type DashboardPageDefinition = {
type: 'dashboard';
};
export type WorkflowNodeDef = {
id: string;
x: number;
y: number;
width: number;
label: 'Trigger' | 'Action';
title: string;
iconName: string;
iconColor?: string;
};
export type WorkflowEdgeDef = {
from: string;
to: string;
type:
| 'branch'
| 'curve'
| 'loopBack'
| 'loopRight'
| 'smoothStep'
| 'vertical';
};
export type WorkflowBranchLabelDef = {
text: string;
x: number;
y: number;
};
export type WorkflowPageDefinition = {
branchLabels?: WorkflowBranchLabelDef[];
edges?: WorkflowEdgeDef[];
header: PageHeader;
nodes?: WorkflowNodeDef[];
plusNode?: { x: number; y: number };
type: 'workflow';
};
@@ -135,9 +168,49 @@ export type KanbanPageDefinition = {
type: 'kanban';
};
export type RecordField = {
avatarUrl?: string;
icon?: string;
label: string;
value: string;
};
export type RecordRelation = {
avatarUrl?: string;
domain?: string;
icon?: SidebarIcon;
name: string;
};
export type RecordNote = {
id: string;
title: string;
body: string;
relation?: { avatarUrl?: string; name: string };
};
export type RecordPageDefinition = {
type: 'record';
header: PageHeader;
record: {
logoDomain?: string;
name: string;
createdAt: string;
fields: RecordField[];
moreCount?: number;
relations: {
title: string;
count?: number;
items: RecordRelation[];
}[];
};
notes: RecordNote[];
};
export type PageDefinition =
| DashboardPageDefinition
| KanbanPageDefinition
| RecordPageDefinition
| TablePageDefinition
| WorkflowPageDefinition;
@@ -6,6 +6,13 @@ export type {
KanbanCard,
KanbanLane,
KanbanPageDefinition,
RecordField,
RecordNote,
RecordPageDefinition,
RecordRelation,
WorkflowBranchLabelDef,
WorkflowEdgeDef,
WorkflowNodeDef,
WorkflowPageDefinition,
CellBoolean,
CellEntity,
@@ -0,0 +1,285 @@
'use client';
import { type ReactNode, useId, useRef, useState } from 'react';
import { Container, LinkButton } from '@/design-system/components';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { TabButtons } from '@/sections/Tabs/components/TabButtons';
import type { TabType } from '@/sections/Tabs/types';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { ProductVisual } from './ProductVisual';
import { useHeroScrollProgress } from './use-hero-scroll-progress';
export type HeroScrollProps = {
aiBody: string;
aiHeading: ReactNode;
ctaHref: string;
ctaLabel: string;
introBody: string;
introHeading: ReactNode;
tabs: TabType[];
visual: AppPreviewConfig;
};
const NAV_HEIGHT = 64;
const ScrollTrack = styled.section`
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: 200vh;
}
`;
const StickyFrame = styled.div`
align-items: center;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
padding-bottom: ${theme.spacing(6)};
padding-top: ${theme.spacing(7.5)};
transition: background-color 0.6s ease;
width: 100%;
&[data-phase='0'] {
background-color: var(--color-white, #ffffff);
}
&[data-phase='1'] {
background-color: var(--color-black, #141414);
}
@media (min-width: ${theme.breakpoints.md}px) {
height: calc(100vh - ${NAV_HEIGHT}px);
padding-bottom: 0;
padding-top: ${theme.spacing(12)};
position: sticky;
top: ${NAV_HEIGHT}px;
}
`;
const PatternOverlay = styled.div`
bottom: 0;
height: 575px;
left: 50%;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translateX(-50%);
transition: opacity 0.6s ease;
width: 100%;
z-index: 0;
&[data-visible='true'] {
opacity: 0.4;
}
`;
const patternImageClassName = css`
object-fit: cover;
`;
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
min-width: 0;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
position: relative;
row-gap: ${theme.spacing(6)};
text-align: center;
width: 100%;
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const HeadingSlot = styled.div`
max-width: 360px;
min-height: 96px;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
position: relative;
transition: color 0.6s ease;
width: 100%;
&[data-phase='0'] {
color: ${theme.colors.primary.text[100]};
}
&[data-phase='1'] {
color: ${theme.colors.secondary.text[100]};
}
`;
const ContentLayer = styled.div`
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 0.6s ease;
&[data-active='true'] {
opacity: 1;
pointer-events: auto;
position: relative;
}
`;
const BodyText = styled.p`
font-size: ${theme.font.size(4)};
line-height: 1.55;
margin: 0;
max-width: 360px;
transition: color 0.6s ease;
width: 100%;
&[data-phase='0'] {
color: ${theme.colors.primary.text[60]};
}
&[data-phase='1'] {
color: rgba(255, 255, 255, 0.7);
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 591px;
}
`;
const HeadingGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
width: 100%;
`;
const ActionSlot = styled.div`
min-height: 48px;
position: relative;
width: 100%;
`;
const CtaLayer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(3)};
inset: 0;
justify-content: center;
opacity: 1;
position: absolute;
transition: opacity 0.4s ease;
&[data-visible='false'] {
opacity: 0;
pointer-events: none;
}
`;
const TabsLayer = styled.div`
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease 0.2s;
width: 100%;
&[data-visible='true'] {
opacity: 1;
pointer-events: auto;
}
@media (min-width: ${theme.breakpoints.md}px) {
inset: 0;
position: absolute;
}
`;
const VisualWrapper = styled.div`
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
`;
export function HeroVisualScroll({
aiBody,
aiHeading,
ctaHref,
ctaLabel,
introBody,
introHeading,
tabs,
visual,
}: HeroScrollProps) {
const trackRef = useRef<HTMLDivElement>(null);
const phase = useHeroScrollProgress(trackRef);
const [activeTab, setActiveTab] = useState(0);
const idPrefix = useId();
const activeScene = phase === 0 ? 0 : activeTab + 1;
return (
<ScrollTrack ref={trackRef}>
<StickyFrame data-phase={phase}>
<PatternOverlay data-visible={phase === 1 ? 'true' : 'false'}>
<NextImage
alt=""
className={patternImageClassName}
fill
sizes="100vw"
src="/images/product/tabs/background.webp"
/>
</PatternOverlay>
<StyledContainer>
<HeadingGroup>
<HeadingSlot data-phase={phase}>
<ContentLayer data-active={phase === 0}>
{introHeading}
</ContentLayer>
<ContentLayer data-active={phase === 1}>{aiHeading}</ContentLayer>
</HeadingSlot>
<BodyText data-phase={phase}>
{phase === 0 ? introBody : aiBody}
</BodyText>
</HeadingGroup>
<ActionSlot>
<CtaLayer data-visible={phase === 0 ? 'true' : 'false'}>
<LinkButton
color="secondary"
href={ctaHref}
label={ctaLabel}
variant="contained"
/>
</CtaLayer>
<TabsLayer data-visible={phase === 1 ? 'true' : 'false'}>
<TabButtons
activeIndex={activeTab}
idPrefix={idPrefix}
onSelect={setActiveTab}
tabs={tabs}
/>
</TabsLayer>
</ActionSlot>
</StyledContainer>
<VisualWrapper>
<ProductVisual activeScene={activeScene} visual={visual} />
</VisualWrapper>
</StickyFrame>
</ScrollTrack>
);
}
@@ -3,13 +3,13 @@
import { styled } from '@linaria/react';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { AppWindow } from '@/sections/AppPreview/AppWindow/AppWindow';
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';
@@ -24,20 +24,16 @@ const StyledRoot = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
margin-top: ${theme.spacing(8)};
margin-top: ${theme.spacing(11)};
}
`;
const ShellScene = styled.div`
aspect-ratio: 1 / 1;
aspect-ratio: 1280 / 832;
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`
@@ -268,10 +264,11 @@ const SendBtn = styled.span`
`;
type ProductVisualProps = {
activeScene?: number;
visual: AppPreviewConfig;
};
export function ProductVisual({ visual }: ProductVisualProps) {
export function ProductVisual({ activeScene, visual }: ProductVisualProps) {
const {
activeItem,
activeLabel,
@@ -280,19 +277,20 @@ export function ProductVisual({ visual }: ProductVisualProps) {
handleSelectLabel,
handleToggleFolder,
highlightedItemId,
isScrollDriven,
openFolderIds,
revealedObjectIds,
selectedOption,
streamComplete,
streamedText,
workspaceNav,
} = useProductVisualAutoplay(visual);
} = useProductVisualAutoplay(visual, { externalScene: activeScene });
const activeHeader = displayPage?.header;
const showViewBar =
displayPage !== null &&
displayPage !== undefined &&
displayPage != null &&
displayPage.type !== 'dashboard' &&
displayPage.type !== 'record' &&
displayPage.type !== 'workflow';
return (
@@ -379,21 +377,19 @@ export function ProductVisual({ visual }: ProductVisualProps) {
<ThinkingText>Thinking...</ThinkingText>
)}
</AiMessages>
{streamComplete ? (
{streamComplete && !isScrollDriven ? (
<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>
))}
{PROMPT_OPTIONS.map((option, index) =>
index === selectedOption ? null : (
<PromptOption
key={option.label}
onClick={() => handleOptionSelect(index)}
>
<PromptOptionIcon>{option.icon}</PromptOptionIcon>
{option.label}
</PromptOption>
),
)}
</PromptOptions>
) : null}
<AiInputArea>
@@ -1,60 +1,317 @@
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
import type {
RecordPageDefinition,
RowDef,
} from '@/sections/AppPreview/types/app-preview-data';
export const NEW_COMPANY_ROW = {
export const NEW_TASK_ROWS: RowDef[] = [
{
id: 'task-follow-up-anthropic',
cells: {
title: {
type: 'text',
value: 'Follow up on Enterprise Expansion',
shortLabel: 'F',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Dario Amodei',
tone: 'gray',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
},
dueDate: { type: 'text', value: 'Nov 1, 2023' },
relatedTo: {
type: 'entity',
name: 'Anthropic',
domain: 'anthropic.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-slack',
cells: {
title: {
type: 'text',
value: 'Schedule renewal call',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Stewart Butterfield',
tone: 'teal',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.stewartButterfield,
},
dueDate: { type: 'text', value: 'Nov 2, 2023' },
relatedTo: {
type: 'entity',
name: 'Slack',
domain: 'slack.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-figma',
cells: {
title: {
type: 'text',
value: 'Send proposal to Dylan',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Dylan Field',
tone: 'purple',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.dylanField,
},
dueDate: { type: 'text', value: 'Nov 3, 2023' },
relatedTo: {
type: 'entity',
name: 'Figma',
domain: 'figma.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-notion',
cells: {
title: {
type: 'text',
value: 'Review consolidation timeline',
shortLabel: 'R',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Ivan Zhao',
tone: 'gray',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.ivanZhao,
},
dueDate: { type: 'text', value: 'Nov 4, 2023' },
relatedTo: {
type: 'entity',
name: 'Notion',
domain: 'notion.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-github',
cells: {
title: {
type: 'text',
value: 'Check in on Copilot Rollout',
shortLabel: 'C',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Thomas Dohmke',
tone: 'gray',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.thomasDohmke,
},
dueDate: { type: 'text', value: 'Nov 5, 2023' },
relatedTo: {
type: 'entity',
name: 'Github',
domain: 'github.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-airbnb',
cells: {
title: {
type: 'text',
value: 'Review Host Ops proposal',
shortLabel: 'R',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Brian Chesky',
tone: 'pink',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.brianChesky,
},
dueDate: { type: 'text', value: 'Nov 6, 2023' },
relatedTo: {
type: 'entity',
name: 'Airbnb',
domain: 'airbnb.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-stripe',
cells: {
title: {
type: 'text',
value: 'Send billing expansion contract',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Patrick Collison',
tone: 'blue',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.patrickCollison,
},
dueDate: { type: 'text', value: 'Nov 6, 2023' },
relatedTo: {
type: 'entity',
name: 'Stripe',
domain: 'stripe.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-sequoia',
cells: {
title: {
type: 'text',
value: 'Schedule quarterly review',
shortLabel: 'S',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Roelof Botha',
tone: 'green',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.roelofBotha,
},
dueDate: { type: 'text', value: 'Nov 7, 2023' },
relatedTo: {
type: 'entity',
name: 'Sequoia',
domain: 'sequoia.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-accel',
cells: {
title: {
type: 'text',
value: 'Follow up on Portfolio Sync',
shortLabel: 'F',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Ping Li',
tone: 'purple',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.pingLi,
},
dueDate: { type: 'text', value: 'Nov 7, 2023' },
relatedTo: {
type: 'entity',
name: 'Accel',
domain: 'accel.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
{
id: 'task-follow-up-google',
cells: {
title: {
type: 'text',
value: 'Prep AI Solutions deck',
shortLabel: 'P',
tone: 'teal',
},
assignee: {
type: 'person',
name: 'Sundar Pichai',
tone: 'teal',
kind: 'person',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.sundarPichai,
},
dueDate: { type: 'text', value: 'Nov 8, 2023' },
relatedTo: {
type: 'entity',
name: 'Google',
domain: 'google.com',
},
status: { type: 'tag', value: 'To Do' },
},
},
];
export const NEW_COMPANY_ROW: RowDef = {
id: 'openai',
cells: {
company: { type: 'entity' as const, name: 'OpenAI', domain: 'openai.com' },
url: { type: 'link' as const, value: 'openai.com' },
company: { type: 'entity', name: 'OpenAI', domain: 'openai.com' },
url: { type: 'link', value: 'openai.com' },
createdBy: {
type: 'person' as const,
type: 'person',
name: 'AI Agent',
tone: 'gray' as const,
kind: 'system' as const,
tone: 'gray',
kind: 'system',
shortLabel: 'AI',
},
address: { type: 'text' as const, value: '3180 18th St' },
address: { type: 'text', value: '3180 18th St' },
accountOwner: {
type: 'person' as const,
type: 'person',
name: 'Sam Altman',
tone: 'amber' as const,
kind: 'person' as const,
tone: 'amber',
kind: 'person',
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' },
icp: { type: 'boolean', value: true },
arr: { type: 'number', value: '$2,000,000' },
linkedin: { type: 'link', value: 'openai' },
industry: { type: 'tag', value: 'AI Research' },
mainContact: {
type: 'person' as const,
type: 'person',
name: 'Sam Altman',
shortLabel: 'S',
tone: 'amber' as const,
kind: 'person' as const,
tone: 'amber',
kind: 'person',
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' },
employees: { type: 'number', value: '3,500' },
opportunities: { type: 'relation', items: [] },
added: { type: 'text', value: 'Just now' },
},
};
export const NEW_PERSON_ROW = {
export const NEW_PERSON_ROW: RowDef = {
id: 'sam-altman',
cells: {
name: {
type: 'person' as const,
type: 'person',
name: 'Sam Altman',
tone: 'amber' as const,
kind: 'person' as const,
tone: 'amber',
kind: 'person',
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' },
company: { type: 'entity', name: 'OpenAI', domain: 'openai.com' },
email: { type: 'link', value: 'sam@openai.com' },
phone: { type: 'text', value: '+1 415 555 0199' },
jobTitle: { type: 'text', value: 'CEO' },
city: { type: 'text', value: 'San Francisco' },
linkedin: { type: 'link', value: 'sama' },
added: { type: 'text', value: 'Just now' },
},
};
@@ -91,19 +348,54 @@ export const PROMPT_OPTIONS = [
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" />
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
),
label: 'Create a dashboard',
navSteps: [
{ at: 0.35, target: 'Dashboards' },
{ at: 0.75, target: 'Sales Dashboard' },
],
label: 'Show me all deals closing this month',
navSteps: [{ at: 0.3, target: 'Opportunities' }],
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.",
'Filtering your pipeline to deals closing this month. Found 7 opportunities worth $12.9M total across Identified, Qualified, and Engaged stages. The biggest: Host Ops with Airbnb at $4,200,000, followed by AI Prototyping with Figma at $3,500,000.',
},
{
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="9 11 12 14 22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
label: 'Create follow-up tasks for my top 10 accounts',
navSteps: [{ at: 0.3, target: 'Tasks' }],
response:
'Creating follow-up tasks for your top 10 accounts by ARR. Done — added 10 tasks:\n\n• "Follow up on Enterprise Expansion" → Anthropic (Nov 1)\n• "Schedule renewal call" → Slack (Nov 2)\n• "Send proposal to Dylan" → Figma (Nov 3)\n• "Review consolidation timeline" → Notion (Nov 4)\n• "Check in on Copilot Rollout" → Github (Nov 5)\n• "Review Host Ops proposal" → Airbnb (Nov 6)\n• "Send billing expansion contract" → Stripe (Nov 6)\n• "Schedule quarterly review" → Sequoia (Nov 7)\n• "Follow up on Portfolio Sync" → Accel (Nov 7)\n• "Prep AI Solutions deck" → Google (Nov 8)\n\nAll assigned to account owners with 7-day deadlines.',
},
{
icon: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
label: "Summarize this customer's history",
navSteps: [{ at: 0.3, target: 'Companies' }],
response:
"Here's the history for Qonto:\n\nLogged a call between Phil Schiller and Steve Anavi — focused on selling through benefits rather than features. Strategy: emphasize how our CRM streamlines operations and improves customer service.\n\nFollow-up with Alexandre Prot to understand their pain points and position our tool as the solution.\n\n3 notes total, 12 people associated, with Q Global Holdings as parent company. Active opportunity in pipeline.",
},
{
icon: (
@@ -119,9 +411,90 @@ export const PROMPT_OPTIONS = [
<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' }],
label: 'Create a workflow that sends an email sequence',
navSteps: [{ at: 0.4, target: 'Send email sequence when deal is engaged' }],
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.',
"I built and activated a workflow that sends an email sequence to each one of the selected People.\n\nThis will only send if the Person has emails.primaryEmail filled in. If some People don't have an email, I'll add a filter step to skip sending when the email is empty (to avoid failures).\n\nIf you want to customize the email subject/body (branding, links, etc.), paste your desired text and I'll update the workflow.",
},
];
export const QONTO_RECORD_PAGE: RecordPageDefinition = {
type: 'record',
header: {
title: 'Qonto',
count: 12,
},
record: {
logoDomain: 'qonto.com',
name: 'Qonto',
createdAt: 'Created 4 hours ago',
fields: [
{ icon: 'link', label: 'URL', value: 'qonto.com' },
{
icon: 'user',
label: 'Account O...',
value: 'Phil Schiller',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.philSchiller,
},
{
icon: 'mapPin',
label: 'Address',
value: '18 Rue De Navarin, 75009 Paris',
},
{ icon: 'check', label: 'ICP', value: '✓ True' },
{ icon: 'currency', label: 'Revenue', value: '$500,000' },
{
icon: 'linkedin',
label: 'Linkedin',
value: 'linkedin.com/company/q...',
},
{ icon: 'twitter', label: 'Twitter', value: '@qonto' },
],
moreCount: 12,
relations: [
{
title: 'Holdings',
items: [{ name: 'Q Global Holdings', domain: 'qonto.com' }],
},
{
title: 'Opportunities',
items: [{ name: 'Qonto', domain: 'qonto.com' }],
},
{
title: 'People',
count: 12,
items: [
{
name: 'Alexandre',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
},
{
name: 'Steve Anavi',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.steveAnavi,
},
],
},
],
},
notes: [
{
id: 'logged-call',
title: 'Logged call (Phil Schiller ↔ Steve Anavi)',
body: 'Apple sells its products by focusing on the benefits users gain from their products, rather than solely highlighting the features. The same approach should be used for selling to Qonto. Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
relation: {
name: 'Alexandre',
avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
},
},
{
id: 'follow-up',
title: 'Follow-up with Alexandre',
body: 'Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
},
{
id: 'third-note',
title: 'Third note',
body: 'Apple sells its products by focusing on the benefits users gain from their products, rather than solely highlighting the features. The same approach should be used for selling to Qonto. Understand their pain points and how your product can alleviate those issues. Emphasize how our CRM tool can help streamline their operations, improve customer service, and ultimately, grow their business.',
},
],
};
@@ -0,0 +1,36 @@
import { type RefObject, useEffect, useState } from 'react';
const TRANSITION_POINT = 0.25;
const MOBILE_BREAKPOINT = 921;
export function useHeroScrollProgress(
trackRef: RefObject<HTMLDivElement | null>,
): number {
const [phase, setPhase] = useState(0);
useEffect(() => {
const element = trackRef.current;
if (!element) return;
if (window.innerWidth < MOBILE_BREAKPOINT) return;
const handleScroll = () => {
const rect = element.getBoundingClientRect();
const scrollableDistance = element.offsetHeight - window.innerHeight;
if (scrollableDistance <= 0) return;
const scrolled = -rect.top;
const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
setPhase(progress >= TRANSITION_POINT ? 1 : 0);
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [trackRef]);
return phase;
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { useAppPreviewState } from '@/sections/AppPreview/Shell/use-app-preview-state';
@@ -6,15 +6,37 @@ import { useAppPreviewState } from '@/sections/AppPreview/Shell/use-app-preview-
import {
NEW_COMPANY_ROW,
NEW_PERSON_ROW,
NEW_TASK_ROWS,
PROMPT_OPTIONS,
QONTO_RECORD_PAGE,
} from './product-visual.data';
export function useProductVisualAutoplay(visual: AppPreviewConfig) {
type AutoplayOptions = {
externalScene?: number;
};
export function useProductVisualAutoplay(
visual: AppPreviewConfig,
options: AutoplayOptions = {},
) {
const { externalScene } = options;
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 [tasksAdded, setTasksAdded] = useState(false);
const [recordReady, setRecordReady] = useState(false);
useEffect(() => {
if (externalScene !== undefined) {
const clamped = Math.max(
0,
Math.min(externalScene, PROMPT_OPTIONS.length - 1),
);
setSelectedOption(clamped);
}
}, [externalScene]);
const {
activeItem,
@@ -29,11 +51,9 @@ export function useProductVisualAutoplay(visual: AppPreviewConfig) {
} = useAppPreviewState(visual);
let displayPage = activePage;
if (
activePage !== null &&
activePage !== undefined &&
activePage.type === 'table'
) {
if (selectedOption === 3 && recordReady) {
displayPage = QONTO_RECORD_PAGE;
} else if (activePage != null && activePage.type === 'table') {
const title = activePage.header?.title;
if (companyAdded && title === 'All Companies') {
displayPage = {
@@ -53,20 +73,55 @@ export function useProductVisualAutoplay(visual: AppPreviewConfig) {
},
rows: [NEW_PERSON_ROW, ...activePage.rows],
};
} else if (tasksAdded && title === 'All Tasks') {
displayPage = {
...activePage,
header: {
...activePage.header,
count: (activePage.header.count ?? 0) + NEW_TASK_ROWS.length,
},
rows: [...NEW_TASK_ROWS, ...activePage.rows],
};
}
}
const isScrollDriven = externalScene !== undefined;
useEffect(() => {
const option = PROMPT_OPTIONS[selectedOption];
const fullText = option.response;
if (isScrollDriven) {
if (selectedOption === 3) {
setRecordReady(true);
} else {
const firstStep = option.navSteps[0];
if (firstStep) {
handleSelectLabel(firstStep.target);
}
}
if (selectedOption === 0) {
setCompanyAdded(true);
}
if (selectedOption === 2) {
setTasksAdded(true);
}
}
let index = 0;
const completedSteps = new Set<number>();
let companyInjected = false;
let personInjected = false;
let tasksInjected = false;
let recordShown = false;
setStreamedText('');
setStreamComplete(false);
setCompanyAdded(false);
setPersonAdded(false);
if (!isScrollDriven) {
setCompanyAdded(false);
setPersonAdded(false);
setTasksAdded(false);
setRecordReady(false);
}
const interval = setInterval(() => {
index += 1;
setStreamedText(fullText.slice(0, index));
@@ -87,27 +142,31 @@ export function useProductVisualAutoplay(visual: AppPreviewConfig) {
setPersonAdded(true);
}
}
if (selectedOption === 2 && !tasksInjected && progress >= 0.3) {
tasksInjected = true;
setTasksAdded(true);
}
if (selectedOption === 3 && !recordShown && progress >= 0.5) {
recordShown = true;
setRecordReady(true);
}
if (index >= fullText.length) {
clearInterval(interval);
setStreamComplete(true);
}
}, 20);
return () => clearInterval(interval);
}, [selectedOption, handleSelectLabel]);
const handleOptionSelect = useCallback(
(optionIndex: number) => setSelectedOption(optionIndex),
[],
);
}, [selectedOption, handleSelectLabel, isScrollDriven]);
return {
activeItem,
activeLabel,
displayPage,
handleOptionSelect,
handleOptionSelect: setSelectedOption,
handleSelectLabel,
handleToggleFolder,
highlightedItemId,
isScrollDriven,
openFolderIds,
revealedObjectIds,
selectedOption,
@@ -3,6 +3,7 @@ import { Cta } from './Cta';
import { Heading } from './Heading';
import { AppPreview } from '@/sections/AppPreview';
import { PartnerVisual } from './PartnerVisual/PartnerVisual';
import { HeroVisualScroll } from './ProductVisual/HeroVisualScroll';
import { ProductVisual } from './ProductVisual/ProductVisual';
import { ReleaseNotesVisual } from './ReleaseNotesVisual';
import { Root } from './Root';
@@ -14,6 +15,7 @@ export const Hero = {
Body,
Cta,
AppPreview,
HeroVisualScroll,
ProductVisual,
ReleaseNotesVisual,
PartnerVisual,
@@ -2,18 +2,15 @@
import { useId, useState } from 'react';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { TabType } from '@/sections/Tabs/types';
import { TabButtons } from './TabButtons';
import { TabVisuals } from './TabVisuals';
const StyledTabGroup = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(18)};
width: 100%;
`;
@@ -33,12 +30,6 @@ export function TabGroup({ tabs }: TabGroupProps) {
onSelect={setActiveIndex}
tabs={tabs}
/>
<TabVisuals
activeIndex={activeIndex}
panelId={`${idPrefix}-panel`}
tabId={`${idPrefix}-tab-${activeIndex}`}
tabs={tabs}
/>
</StyledTabGroup>
);
}
@@ -1,46 +0,0 @@
'use client';
import { Image } from '@/design-system/components';
import type { TabType } from '@/sections/Tabs/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const VisualRoot = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
margin-left: auto;
margin-right: auto;
max-width: 995px;
}
`;
const VisualImage = styled(Image)`
border-radius: ${theme.radius(1)};
`;
type TabVisualsProps = {
activeIndex: number;
panelId: string;
tabId: string;
tabs: TabType[];
};
export function TabVisuals({
activeIndex,
panelId,
tabId,
tabs,
}: TabVisualsProps) {
const tab = tabs[activeIndex];
if (!tab) {
return null;
}
return (
<VisualRoot aria-labelledby={tabId} id={panelId} role="tabpanel">
<VisualImage alt={tab.image.alt} src={tab.image.src} />
</VisualRoot>
);
}
@@ -1,9 +1,6 @@
import type { MessageDescriptor } from '@lingui/core';
import type { ImageType } from '@/design-system/components/Image';
export type TabType = {
body: MessageDescriptor;
icon: string;
image: ImageType;
};