From f9e3683518b72368c46d2f6ed899786db0802ff2 Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Tue, 19 May 2026 15:12:44 +0500
Subject: [PATCH] [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
---
.../app/[locale]/(home)/app-preview.data.ts | 73 +++
.../app/[locale]/product/ai-hero-tabs.data.ts | 22 +
.../src/app/[locale]/product/page.tsx | 130 ++--
.../src/app/[locale]/product/tabs.data.ts | 38 --
.../src/sections/AppPreview/AppPreview.tsx | 1 +
.../AppPreview/AppWindow/AppWindow.tsx | 55 +-
.../AppPreview/Pages/Kanban/KanbanLane.tsx | 19 +-
.../AppPreview/Pages/Kanban/KanbanPage.tsx | 1 +
.../AppPreview/Pages/Record/RecordPage.tsx | 581 ++++++++++++++++++
.../Pages/Workflow/WorkflowEdges.tsx | 60 +-
.../Pages/Workflow/WorkflowNode.tsx | 30 +-
.../Pages/Workflow/WorkflowPage.tsx | 60 +-
.../Pages/Workflow/resolve-workflow-nodes.ts | 42 ++
.../Pages/Workflow/workflow-page-data.ts | 8 +-
.../Pages/Workflow/workflow-page-geometry.ts | 85 +++
.../AppPreview/Shell/PageRenderers.tsx | 19 +-
.../Shell/preload-deferred-pages.ts | 7 +-
.../src/sections/AppPreview/index.ts | 1 +
.../AppPreview/types/app-preview-data.ts | 73 +++
.../src/sections/AppPreview/types/index.ts | 7 +
.../ProductVisual/HeroVisualScroll.tsx | 285 +++++++++
.../ProductVisual/ProductVisual.tsx | 46 +-
.../ProductVisual/product-visual.data.tsx | 461 ++++++++++++--
.../ProductVisual/use-hero-scroll-progress.ts | 36 ++
.../use-product-visual-autoplay.ts | 91 ++-
.../src/sections/Hero/components/index.ts | 2 +
.../src/sections/Tabs/components/TabGroup.tsx | 9 -
.../sections/Tabs/components/TabVisuals.tsx | 46 --
.../src/sections/Tabs/types/index.ts | 3 -
29 files changed, 1977 insertions(+), 314 deletions(-)
create mode 100644 packages/twenty-website-new/src/app/[locale]/product/ai-hero-tabs.data.ts
delete mode 100644 packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
create mode 100644 packages/twenty-website-new/src/sections/AppPreview/Pages/Record/RecordPage.tsx
create mode 100644 packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/resolve-workflow-nodes.ts
create mode 100644 packages/twenty-website-new/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
create mode 100644 packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
delete mode 100644 packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx
diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/app-preview.data.ts b/packages/twenty-website-new/src/app/[locale]/(home)/app-preview.data.ts
index 259650f142..1d8b636fe9 100644
--- a/packages/twenty-website-new/src/app/[locale]/(home)/app-preview.data.ts
+++ b/packages/twenty-website-new/src/app/[locale]/(home)/app-preview.data.ts
@@ -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',
diff --git a/packages/twenty-website-new/src/app/[locale]/product/ai-hero-tabs.data.ts b/packages/twenty-website-new/src/app/[locale]/product/ai-hero-tabs.data.ts
new file mode 100644
index 0000000000..9e4da720ae
--- /dev/null
+++ b/packages/twenty-website-new/src/app/[locale]/product/ai-hero-tabs.data.ts
@@ -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',
+ },
+];
diff --git a/packages/twenty-website-new/src/app/[locale]/product/page.tsx b/packages/twenty-website-new/src/app/[locale]/product/page.tsx
index f0cf367e96..13b97e819a 100644
--- a/packages/twenty-website-new/src/app/[locale]/product/page.tsx
+++ b/packages/twenty-website-new/src/app/[locale]/product/page.tsx
@@ -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) {
-
-
-
- {i18n._(msg`A CRM for teams`)}
-
-
- {i18n._(msg`that`)}{' '}
- {i18n._(msg`moves fast`)}
-
-
- {i18n._(
- msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
- )}
-
-
-
-
-
-
+
+
+ {i18n._(msg`AI that actually`)}
+
+
+
+ {i18n._(msg`helps you`)}
+ {' '}
+
+ {i18n._(msg`work faster`)}
+
+
+ }
+ 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={
+
+
+ {i18n._(msg`A CRM for teams`)}
+
+
+
+ {i18n._(msg`that`)}
+ {' '}
+
+ {i18n._(msg`move fast`)}
+
+
+ }
+ tabs={AI_HERO_TABS}
+ visual={APP_PREVIEW_DATA.visual}
+ />
-
-
-
- {i18n._(msg`AI & Automation`)}
-
-
-
-
- {i18n._(msg`AI that actually`)}
-
-
-
- {i18n._(msg`helps you`)}
- {' '}
-
- {i18n._(msg`work faster`)}
-
-
-
- {i18n._(msg`The AI understands your CRM and takes action.`)}
-
-
-
-
diff --git a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts b/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
deleted file mode 100644
index f46a5caf4d..0000000000
--- a/packages/twenty-website-new/src/app/[locale]/product/tabs.data.ts
+++ /dev/null
@@ -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',
- },
- },
-];
diff --git a/packages/twenty-website-new/src/sections/AppPreview/AppPreview.tsx b/packages/twenty-website-new/src/sections/AppPreview/AppPreview.tsx
index 6d7118547b..b96b1e1c0d 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/AppPreview.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/AppPreview.tsx
@@ -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(
diff --git a/packages/twenty-website-new/src/sections/AppPreview/AppWindow/AppWindow.tsx b/packages/twenty-website-new/src/sections/AppPreview/AppWindow/AppWindow.tsx
index 043b3dd99a..31141b6073 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/AppWindow/AppWindow.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/AppWindow/AppWindow.tsx
@@ -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(null);
+ const interactingRef = useRef(false);
const [position, setPosition] = useState(null);
const [size, setSize] = useState(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,
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanLane.tsx b/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanLane.tsx
index e56da7d095..1da47d97f0 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanLane.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanLane.tsx
@@ -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.label}
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanPage.tsx b/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanPage.tsx
index ed9996e256..b73c26f4a4 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanPage.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Kanban/KanbanPage.tsx
@@ -41,6 +41,7 @@ export function KanbanPage({ page }: { page: KanbanPageDefinition }) {
{page.lanes.map((lane, index) => (
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Record/RecordPage.tsx b/packages/twenty-website-new/src/sections/AppPreview/Pages/Record/RecordPage.tsx
new file mode 100644
index 0000000000..582b272058
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Record/RecordPage.tsx
@@ -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 (
+
+
+ {field.value}
+
+ );
+ }
+ if (field.value.includes('.com') || field.value.startsWith('@')) {
+ return {field.value};
+ }
+ return {field.value};
+}
+
+export function RecordPage({ page }: { page: RecordPageDefinition }) {
+ const { record, notes } = page;
+
+ return (
+
+
+
+
+ {record.name}
+ {record.createdAt}
+
+
+
+ {record.fields.map((field, index) => (
+
+
+ {field.icon ? (
+
+ ) : null}
+
+ {field.label}
+
+
+ ))}
+
+
+ {record.moreCount ? (
+
+
+ More ({record.moreCount})
+
+ ) : null}
+
+
+
+ {record.relations.map((section) => (
+
+
+ {section.title}
+ {section.count ? (
+ All ({section.count})
+ ) : null}
+
+ {section.items.map((item) => (
+
+ {item.avatarUrl ? (
+
+ ) : item.domain ? (
+
+ ) : (
+
+ )}
+ {item.name}
+
+ ))}
+
+ ))}
+
+
+
+
+ {RECORD_TABS.map((tab) => (
+
+
+ {tab.label}
+
+ ))}
+
+
+
+ All {notes.length}
+ + Add note
+
+
+
+ {notes.map((note, index) => (
+
+ {note.title}
+ {note.body}
+ {note.relation ? (
+
+
+
+
+ Relations:
+ {note.relation.avatarUrl ? (
+
+ ) : null}
+ {note.relation.name}
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
+
+function FieldIconPath({ iconName }: { iconName: string }) {
+ switch (iconName) {
+ case 'link':
+ return (
+
+ );
+ case 'user':
+ return (
+ <>
+
+
+ >
+ );
+ case 'mapPin':
+ return (
+ <>
+
+
+ >
+ );
+ case 'check':
+ return ;
+ case 'currency':
+ return (
+ <>
+
+
+ >
+ );
+ case 'linkedin':
+ return (
+ <>
+
+
+
+ >
+ );
+ case 'twitter':
+ return (
+
+ );
+ default:
+ return ;
+ }
+}
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowEdges.tsx b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowEdges.tsx
index a1247c2cec..266ac09a99 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowEdges.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowEdges.tsx
@@ -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;
nodes: ReadonlyArray;
+ plusNode?: { x: number; y: number };
}) {
+ const completedEdge = edges.find((e) => e.type === 'loopRight');
+ const completedSourceNode = completedEdge
+ ? getWorkflowNodeById(nodes, completedEdge.from)
+ : undefined;
+
return (
{edges.map((edge) => (
))}
+ {plusNode && completedSourceNode && (
+
+ )}
+ {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 (
+
+
+
+
+
+ );
+ })}
);
}
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowNode.tsx b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowNode.tsx
index bf0c32f437..37cf8b29cc 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowNode.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/WorkflowNode.tsx
@@ -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 & { index?: number }) {
return (
-
+
@@ -106,14 +133,18 @@ export function WorkflowPage({ page }: { page: WorkflowPageDefinition }) {
>
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/resolve-workflow-nodes.ts b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/resolve-workflow-nodes.ts
new file mode 100644
index 0000000000..692d2eb9b8
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/resolve-workflow-nodes.ts
@@ -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 = {
+ 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,
+ }));
+}
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-data.ts b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-data.ts
index 67ac35434b..91a256b32c 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-data.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-data.ts
@@ -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[] = [
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-geometry.ts b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-geometry.ts
index 3c78df7925..8f028e52d6 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-geometry.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/Pages/Workflow/workflow-page-geometry.ts
@@ -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;
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Shell/PageRenderers.tsx b/packages/twenty-website-new/src/sections/AppPreview/Shell/PageRenderers.tsx
index 04e9866a01..6698ecfa97 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Shell/PageRenderers.tsx
+++ b/packages/twenty-website-new/src/sections/AppPreview/Shell/PageRenderers.tsx
@@ -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: () => ,
- ssr: false,
- },
-);
-
const PAGE_RENDERERS = {
table: (page: TablePageDefinition) => ,
kanban: (page: KanbanPageDefinition) => ,
@@ -68,6 +58,7 @@ const PAGE_RENDERERS = {
),
+ record: (page: RecordPageDefinition) => ,
workflow: (page: WorkflowPageDefinition) => ,
} satisfies {
[K in PageType]: (page: Extract) => 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);
}
diff --git a/packages/twenty-website-new/src/sections/AppPreview/Shell/preload-deferred-pages.ts b/packages/twenty-website-new/src/sections/AppPreview/Shell/preload-deferred-pages.ts
index 2f6e763411..a2351d12ba 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/Shell/preload-deferred-pages.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/Shell/preload-deferred-pages.ts
@@ -1,15 +1,10 @@
const loadSalesDashboardPageModule = () =>
import('../Pages/Dashboard/SalesDashboardPage');
-const loadWorkflowPageModule = () => import('../Pages/Workflow/WorkflowPage');
-
let deferredPagePreloadPromise: Promise | null = null;
export function preloadDeferredPages() {
- deferredPagePreloadPromise ??= Promise.all([
- loadSalesDashboardPageModule(),
- loadWorkflowPageModule(),
- ])
+ deferredPagePreloadPromise ??= loadSalesDashboardPageModule()
.then(() => undefined)
.catch((error: unknown) => {
deferredPagePreloadPromise = null;
diff --git a/packages/twenty-website-new/src/sections/AppPreview/index.ts b/packages/twenty-website-new/src/sections/AppPreview/index.ts
index 0e4b1881d4..e214795783 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/index.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/index.ts
@@ -8,5 +8,6 @@ export type {
DashboardData,
DashboardPageDefinition,
KanbanPageDefinition,
+ RecordPageDefinition,
TablePageDefinition,
} from './types';
diff --git a/packages/twenty-website-new/src/sections/AppPreview/types/app-preview-data.ts b/packages/twenty-website-new/src/sections/AppPreview/types/app-preview-data.ts
index 222aee3126..7c6d9a8dbd 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/types/app-preview-data.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/types/app-preview-data.ts
@@ -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;
diff --git a/packages/twenty-website-new/src/sections/AppPreview/types/index.ts b/packages/twenty-website-new/src/sections/AppPreview/types/index.ts
index 54620f848e..e88afd8762 100644
--- a/packages/twenty-website-new/src/sections/AppPreview/types/index.ts
+++ b/packages/twenty-website-new/src/sections/AppPreview/types/index.ts
@@ -6,6 +6,13 @@ export type {
KanbanCard,
KanbanLane,
KanbanPageDefinition,
+ RecordField,
+ RecordNote,
+ RecordPageDefinition,
+ RecordRelation,
+ WorkflowBranchLabelDef,
+ WorkflowEdgeDef,
+ WorkflowNodeDef,
WorkflowPageDefinition,
CellBoolean,
CellEntity,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
new file mode 100644
index 0000000000..cb79c82b83
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
@@ -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(null);
+ const phase = useHeroScrollProgress(trackRef);
+ const [activeTab, setActiveTab] = useState(0);
+ const idPrefix = useId();
+
+ const activeScene = phase === 0 ? 0 : activeTab + 1;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {introHeading}
+
+ {aiHeading}
+
+
+
+ {phase === 0 ? introBody : aiBody}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/ProductVisual.tsx b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
index bfee5f9421..39bce96cce 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
@@ -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) {
Thinking...
)}
- {streamComplete ? (
+ {streamComplete && !isScrollDriven ? (
- {PROMPT_OPTIONS.filter(
- (_, index) => index !== selectedOption,
- ).map((option, index) => (
-
- handleOptionSelect(PROMPT_OPTIONS.indexOf(option))
- }
- >
- {option.icon}
- {option.label}
-
- ))}
+ {PROMPT_OPTIONS.map((option, index) =>
+ index === selectedOption ? null : (
+ handleOptionSelect(index)}
+ >
+ {option.icon}
+ {option.label}
+
+ ),
+ )}
) : null}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/product-visual.data.tsx b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
index 583b8617fe..7145496f79 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
+++ b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
@@ -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"
>
-
-
-
-
+
+
),
- 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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 = [
),
- 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.',
+ },
+ ],
+};
diff --git a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
new file mode 100644
index 0000000000..08ddf0f395
--- /dev/null
+++ b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
@@ -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,
+): 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;
+}
diff --git a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
index 3b2001e0e5..4b05a906b4 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
@@ -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(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();
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,
diff --git a/packages/twenty-website-new/src/sections/Hero/components/index.ts b/packages/twenty-website-new/src/sections/Hero/components/index.ts
index e90d200c8b..b0a9823b9f 100644
--- a/packages/twenty-website-new/src/sections/Hero/components/index.ts
+++ b/packages/twenty-website-new/src/sections/Hero/components/index.ts
@@ -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,
diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx
index 1de2429852..7caffba4a2 100644
--- a/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx
+++ b/packages/twenty-website-new/src/sections/Tabs/components/TabGroup.tsx
@@ -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}
/>
-
);
}
diff --git a/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx b/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx
deleted file mode 100644
index b18612cf3f..0000000000
--- a/packages/twenty-website-new/src/sections/Tabs/components/TabVisuals.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-}
diff --git a/packages/twenty-website-new/src/sections/Tabs/types/index.ts b/packages/twenty-website-new/src/sections/Tabs/types/index.ts
index cff9dc7f44..52161c1c0a 100644
--- a/packages/twenty-website-new/src/sections/Tabs/types/index.ts
+++ b/packages/twenty-website-new/src/sections/Tabs/types/index.ts
@@ -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;
};