) => ReactNode;
-};
-
export function renderPageDefinition(
page: PageDefinition,
- onNavigateToLabel?: (label: string) => void,
- pageKey?: string,
+ onNavigateToPageItemId?: (itemId: string) => void,
) {
switch (page.type) {
case 'table':
return (
);
case 'kanban':
- return PAGE_RENDERERS.kanban(page);
+ return ;
case 'dashboard':
- return PAGE_RENDERERS.dashboard(page);
+ return (
+
+
+
+ );
case 'record':
- return PAGE_RENDERERS.record(page);
+ return ;
case 'workflow':
- return PAGE_RENDERERS.workflow(page);
+ return ;
}
}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarControls.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarControls.tsx
index 2b72331d7a..74842e0e5b 100644
--- a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarControls.tsx
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarControls.tsx
@@ -3,152 +3,118 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
- IconHome2,
- IconMessageCircle,
+ IconHome,
+ IconMessageCircle2,
IconMessageCirclePlus,
} from '@tabler/icons-react';
-import {
- APP_FONT,
- COLORS,
- TABLER_STROKE,
-} from '../Shared/utils/app-preview-theme';
+import { APP_FONT } from '../Shared/utils/app-preview-theme';
-const SidebarControlsRoot = styled.div`
- align-items: center;
- display: grid;
- gap: 8px;
- grid-auto-flow: column;
- grid-template-columns: auto;
+const Root = styled.div<{ $desktopExpanded: boolean }>`
+ display: flex;
justify-content: center;
- min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
- display: flex;
- gap: 12px;
- grid-auto-flow: row;
- justify-content: space-between;
+ align-items: center;
+ justify-content: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? 'space-between' : 'center'};
}
`;
-const SegmentedRail = styled.div`
- background: #fcfcfccc;
- border: 1px solid ${COLORS.border};
- border-radius: 40px;
+const SegmentedRail = styled.div<{ $desktopExpanded: boolean }>`
display: none;
- gap: 2px;
- grid-auto-flow: column;
- padding: 3px;
@media (min-width: ${theme.breakpoints.md}px) {
- display: grid;
+ display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
+ align-items: center;
+ background-color: #fcfcfccc;
+ border: 1px solid #ebebeb;
+ border-radius: 999px;
+ column-gap: 2px;
+ height: 26px;
+ padding-bottom: 3px;
+ padding-left: 3px;
+ padding-right: 3px;
+ padding-top: 3px;
}
`;
const Segment = styled.div<{ $selected?: boolean }>`
align-items: center;
background: ${({ $selected }) => ($selected ? '#0000000a' : 'transparent')};
- border-radius: 16px;
+ border-radius: 999px;
display: flex;
- height: 22px;
+ height: 20px;
justify-content: center;
- width: 22px;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- padding: 0 8px;
- width: 32px;
- }
+ width: 32px;
`;
-const NewChat = styled.div`
+const NewChat = styled.div<{ $desktopExpanded: boolean }>`
align-items: center;
- background: ${COLORS.backgroundSecondary};
- border: 1px solid ${COLORS.border};
- border-radius: 40px;
- color: ${COLORS.textSecondary};
+ background-color: #fcfcfc;
+ border: 1px solid #ebebeb;
+ border-radius: 999px;
+ color: #666666;
display: flex;
- gap: 4px;
- height: 28px;
+ height: 32px;
justify-content: center;
- min-width: 0;
- padding: 3px;
- width: 28px;
+ width: 32px;
@media (min-width: ${theme.breakpoints.md}px) {
- width: 103px;
+ column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '4px' : '0')};
+ height: ${({ $desktopExpanded }) => ($desktopExpanded ? '26px' : '32px')};
+ padding-bottom: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? '3px' : '0'};
+ padding-left: ${({ $desktopExpanded }) => ($desktopExpanded ? '3px' : '0')};
+ padding-right: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? '3px' : '0'};
+ padding-top: ${({ $desktopExpanded }) => ($desktopExpanded ? '3px' : '0')};
+ width: ${({ $desktopExpanded }) => ($desktopExpanded ? '103px' : '32px')};
}
`;
-const NewChatLabel = styled.span`
+const NewChatLabel = styled.span<{ $desktopExpanded: boolean }>`
display: none;
- font-family: ${APP_FONT};
- font-size: 13px;
- font-weight: ${theme.font.weight.medium};
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
@media (min-width: ${theme.breakpoints.md}px) {
- display: block;
+ display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'block' : 'none')};
+ font-family: ${APP_FONT};
+ font-size: 13px;
+ font-weight: ${theme.font.weight.medium};
+ line-height: 1.4;
}
`;
-type MiniIconProps = {
- color?: string;
- size?: number;
+type SidebarControlsProps = {
+ desktopExpanded: boolean;
};
-function HomeMini({ color = COLORS.textSecondary, size = 16 }: MiniIconProps) {
+export function SidebarControls({ desktopExpanded }: SidebarControlsProps) {
return (
-
- );
-}
-
-function CommentMini({
- color = COLORS.textTertiary,
- size = 16,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-function MessageCirclePlusMini({
- color = COLORS.textSecondary,
- size = 16,
-}: MiniIconProps) {
- return (
-
- );
-}
-
-export function SidebarControls() {
- return (
-
-
+
+
-
+
-
+
-
-
- New chat
+
+
+
+ New chat
-
+
);
}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopFolder.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopFolder.tsx
new file mode 100644
index 0000000000..265d95a339
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopFolder.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { IconChevronDown } from '@tabler/icons-react';
+
+import type { SidebarFolderDef } from '../types';
+import { MiniIcon } from '../Shared/components/MiniIcon';
+import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
+import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
+import {
+ DesktopBranchLine,
+ DesktopChildStack,
+ SidebarDesktopItem,
+} from './SidebarDesktopItem';
+
+const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
+
+const FolderButton = styled.button<{ $expanded?: boolean }>`
+ align-items: center;
+ background: ${({ $expanded }) =>
+ $expanded ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ height: 28px;
+ justify-content: flex-start;
+ padding-bottom: 0;
+ padding-left: 4px;
+ padding-right: 2px;
+ padding-top: 0;
+ transition: background-color 0.14s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ }
+`;
+
+const FolderRowMain = styled.div`
+ align-items: center;
+ column-gap: 8px;
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+`;
+
+const FolderText = styled.span`
+ color: ${COLORS.textSecondary};
+ font-family: ${APP_FONT};
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const FolderChevron = styled.div<{ $expanded?: boolean }>`
+ color: ${COLORS.textTertiary};
+ display: flex;
+ flex: 0 0 auto;
+ margin-left: auto;
+ transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')});
+ transition: transform 0.16s ease;
+`;
+
+type SidebarDesktopFolderProps = {
+ expanded: boolean;
+ folder: SidebarFolderDef;
+ highlightedItemId?: string;
+ onSelectItem?: (itemId: string) => void;
+ onToggleExpanded: () => void;
+ selectedItemId?: string;
+};
+
+export function SidebarDesktopFolder({
+ expanded,
+ folder,
+ highlightedItemId,
+ onSelectItem,
+ onToggleExpanded,
+ selectedItemId,
+}: SidebarDesktopFolderProps) {
+ const hasActiveChild = folder.items.some(
+ (item) => item.id === selectedItemId,
+ );
+
+ return (
+ <>
+
+
+ {renderPreviewIcon(folder.icon)}
+ {folder.label}
+
+
+
+
+
+ {expanded ? (
+
+
+ {folder.items.map((item, index) => (
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopItem.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopItem.tsx
new file mode 100644
index 0000000000..d75d3af045
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarDesktopItem.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarItemDef } from '../types';
+import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
+import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
+import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
+
+const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
+
+const DesktopItemButton = styled.button<{
+ $active?: boolean;
+ $depth?: number;
+ $highlightRgb?: string;
+ $highlighted?: boolean;
+}>`
+ --highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
+ align-items: center;
+ animation: ${({ $highlighted }) =>
+ $highlighted
+ ? 'sidebarDesktopItemAppear 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
+ : 'none'};
+ appearance: none;
+ background: ${({ $active }) =>
+ $active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ height: 28px;
+ justify-content: flex-start;
+ padding-bottom: 0;
+ padding-left: ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ padding-right: 2px;
+ padding-top: 0;
+ position: relative;
+ text-align: left;
+ transition: background-color 0.14s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ }
+
+ @keyframes sidebarDesktopItemAppear {
+ 0% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ box-shadow:
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ opacity: 0;
+ transform: translateX(-32px) translateY(-6px) scale(0.6);
+ }
+ 16% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
+ box-shadow:
+ 0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
+ 0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
+ opacity: 1;
+ transform: translateX(0) translateY(0) scale(1.18);
+ }
+ 32% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0.42);
+ box-shadow:
+ 0 0 0 12px rgba(var(--highlight-rgb, 237, 95, 0), 0.24),
+ 0 10px 22px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.38);
+ transform: translateX(0) scale(0.97);
+ }
+ 50% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0.28);
+ box-shadow:
+ 0 0 0 18px rgba(var(--highlight-rgb, 237, 95, 0), 0.12),
+ 0 6px 16px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.22);
+ transform: translateX(0) scale(1.02);
+ }
+ 72% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0.16);
+ box-shadow:
+ 0 0 0 22px rgba(var(--highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ transform: translateX(0) scale(1);
+ }
+ 100% {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ box-shadow:
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ transform: translateX(0) scale(1);
+ }
+ }
+`;
+
+const DesktopItemLink = styled.a<{ $active?: boolean; $depth?: number }>`
+ align-items: center;
+ background: ${({ $active }) =>
+ $active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ height: 28px;
+ justify-content: flex-start;
+ padding-bottom: 0;
+ padding-left: ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
+ padding-right: 2px;
+ padding-top: 0;
+ position: relative;
+ text-decoration: none;
+ transition: background-color 0.14s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ }
+`;
+
+export const DesktopBranchCell = styled.div<{ $isLastChild?: boolean }>`
+ align-self: stretch;
+ flex: 0 0 9px;
+ position: relative;
+
+ &::before {
+ background: ${COLORS.borderStrong};
+ content: '';
+ inset: 0 8px 0 0;
+ opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)};
+ position: absolute;
+ }
+
+ &::after {
+ border-bottom: 1px solid ${COLORS.borderStrong};
+ border-left: 1px solid ${COLORS.borderStrong};
+ border-radius: 0 0 0 4px;
+ content: '';
+ inset: 0 0 12px 0;
+ position: absolute;
+ }
+`;
+
+const DesktopRowMain = styled.div<{ $withBranch?: boolean }>`
+ align-items: center;
+ column-gap: 8px;
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')};
+`;
+
+const DesktopItemText = styled.div`
+ align-items: center;
+ column-gap: 2px;
+ display: flex;
+ min-width: 0;
+`;
+
+const DesktopItemLabel = styled.span<{ $active?: boolean }>`
+ color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)};
+ font-family: ${APP_FONT};
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const DesktopItemMeta = styled.span`
+ color: ${COLORS.textLight};
+ font-family: ${APP_FONT};
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+type SidebarDesktopItemProps = {
+ depth?: number;
+ highlightedItemId?: string;
+ isLastChild?: boolean;
+ item: SidebarItemDef;
+ onSelect?: (itemId: string) => void;
+ selectedItemId?: string;
+};
+
+export function SidebarDesktopItem({
+ depth = 0,
+ highlightedItemId,
+ isLastChild = false,
+ item,
+ onSelect,
+ selectedItemId,
+}: SidebarDesktopItemProps) {
+ const showBranch = depth > 0;
+ const rowActive = selectedItemId !== undefined && item.id === selectedItemId;
+ const rowHighlighted = highlightedItemId === item.id;
+ const highlightRgb = getSidebarIconToneRgb(item.icon);
+
+ const rowContent = (
+ <>
+ {showBranch ? : null}
+
+ {renderPreviewIcon(item.icon, rowHighlighted)}
+
+ {item.label}
+ {item.meta ? · {item.meta} : null}
+
+
+ >
+ );
+
+ if (item.href) {
+ return (
+
+ {rowContent}
+
+ );
+ }
+
+ return (
+ onSelect(item.id) : undefined}
+ type="button"
+ >
+ {rowContent}
+
+ );
+}
+
+export const DesktopBranchLine = styled.div`
+ background: ${COLORS.borderStrong};
+ bottom: 14px;
+ left: 11px;
+ position: absolute;
+ top: 0;
+ width: 1px;
+`;
+
+export const DesktopChildStack = styled.div`
+ display: flex;
+ flex-direction: column;
+ row-gap: 2px;
+ position: relative;
+`;
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarFavorites.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarFavorites.tsx
new file mode 100644
index 0000000000..1067359aa6
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarFavorites.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarItemDef } from '../types';
+import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
+import { SidebarDesktopItem } from './SidebarDesktopItem';
+
+const FavoritesSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ row-gap: 2px;
+`;
+
+const FavoritesLabelRow = styled.div`
+ align-items: center;
+ display: flex;
+ height: 28px;
+ padding-left: 4px;
+ padding-right: 2px;
+`;
+
+const FavoritesLabel = styled.span`
+ color: ${COLORS.textLight};
+ font-family: ${APP_FONT};
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+`;
+
+type SidebarFavoritesProps = {
+ favoritesNav: SidebarItemDef[];
+ highlightedItemId?: string;
+ onSelectPageItem: (itemId: string) => void;
+ selectedItemId: string;
+};
+
+export function SidebarFavorites({
+ favoritesNav,
+ highlightedItemId,
+ onSelectPageItem,
+ selectedItemId,
+}: SidebarFavoritesProps) {
+ return (
+
+
+ Favorites
+
+ {favoritesNav.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarHeader.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarHeader.tsx
index cb0c9380a0..4e0c824255 100644
--- a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarHeader.tsx
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarHeader.tsx
@@ -8,133 +8,116 @@ import {
IconSearch,
} from '@tabler/icons-react';
-import { MiniIcon } from '../Shared/components/MiniIcon';
-import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
+import { APP_FONT } from '../Shared/utils/app-preview-theme';
const APPLE_WORKSPACE_LOGO_SRC = '/images/home/hero/apple-rainbow-logo.svg';
-const SidebarTopBar = styled.div`
- align-items: center;
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- min-height: 32px;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- gap: 8px;
- grid-template-columns: minmax(0, 1fr) auto;
- }
-`;
-
-const WorkspaceMenu = styled.div`
- align-items: center;
- display: grid;
- gap: 4px;
- grid-auto-flow: column;
- grid-template-columns: auto;
- justify-content: center;
- min-width: 0;
- padding: 6px 4px;
-
- > svg:last-child {
- display: none;
- }
-
- @media (min-width: ${theme.breakpoints.md}px) {
- gap: 8px;
- grid-auto-flow: row;
- grid-template-columns: auto 1fr auto;
- justify-content: stretch;
-
- > svg:last-child {
- display: block;
- }
- }
-`;
-
-const WorkspaceIcon = styled.div`
+const Header = styled.div<{ $desktopExpanded: boolean }>`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ row-gap: 16px;
+ padding-bottom: 8px;
+ padding-left: 4px;
+ padding-right: 4px;
+ padding-top: 8px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ flex-direction: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? 'row' : 'column'};
+ justify-content: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? 'space-between' : 'flex-start'};
+ row-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '0' : '16px')};
+ }
+`;
+
+const LeftGroup = styled.div<{ $desktopExpanded: boolean }>`
align-items: center;
display: flex;
- flex: 0 0 auto;
- height: 16px;
justify-content: center;
+ width: 100%;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '8px' : '0')};
+ justify-content: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? 'flex-start' : 'center'};
+ width: ${({ $desktopExpanded }) => ($desktopExpanded ? 'auto' : '100%')};
+ }
+`;
+
+const Logo = styled.img`
+ display: block;
+ height: 16px;
+ object-fit: contain;
width: 16px;
`;
-const WorkspaceIconImage = styled.img`
- display: block;
- height: 100%;
- object-fit: contain;
- object-position: center;
- width: 100%;
-`;
-
-const WorkspaceLabel = styled.span`
- color: ${COLORS.text};
+const Name = styled.span<{ $desktopExpanded: boolean }>`
display: none;
- font-family: ${APP_FONT};
- font-size: 13px;
- font-weight: ${theme.font.weight.medium};
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
@media (min-width: ${theme.breakpoints.md}px) {
- display: block;
+ display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'block' : 'none')};
+ color: #333333;
+ font-family: ${APP_FONT};
+ font-size: 13px;
+ font-weight: ${theme.font.weight.medium};
+ line-height: 1.4;
}
`;
-const SidebarTopActions = styled.div`
- align-items: center;
+const Chevron = styled.span<{ $desktopExpanded: boolean }>`
display: none;
- gap: 2px;
- grid-auto-flow: column;
@media (min-width: ${theme.breakpoints.md}px) {
- display: grid;
+ display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
}
`;
-const SidebarIconButton = styled.div`
+const RightGroup = styled.div<{ $desktopExpanded: boolean }>`
align-items: center;
- border-radius: 4px;
display: flex;
- height: 24px;
- justify-content: center;
- width: 24px;
+ flex-direction: column;
+ row-gap: 16px;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ column-gap: ${({ $desktopExpanded }) => ($desktopExpanded ? '8px' : '0')};
+ flex-direction: ${({ $desktopExpanded }) =>
+ $desktopExpanded ? 'row' : 'column'};
+ }
+`;
+
+const CollapseButton = styled.span<{ $desktopExpanded: boolean }>`
+ display: none;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ display: ${({ $desktopExpanded }) => ($desktopExpanded ? 'flex' : 'none')};
+ }
`;
type SidebarHeaderProps = {
- workspaceName: string;
+ desktopExpanded: boolean;
};
-export function SidebarHeader({ workspaceName }: SidebarHeaderProps) {
+export function SidebarHeader({ desktopExpanded }: SidebarHeaderProps) {
return (
-
-
-
-
-
- {workspaceName}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Apple
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarItem.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarItem.tsx
deleted file mode 100644
index b07ef8f6fc..0000000000
--- a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarItem.tsx
+++ /dev/null
@@ -1,353 +0,0 @@
-'use client';
-
-import { theme } from '@/theme';
-import { styled } from '@linaria/react';
-import { IconChevronDown } from '@tabler/icons-react';
-
-import type { SidebarItemDef } from '../types';
-import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
-import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
-import { MiniIcon } from '../Shared/components/MiniIcon';
-import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
-import { VISUAL_TOKENS } from '../Shared/utils/app-preview-tokens';
-
-const SidebarItemRow = styled.div<{
- $active?: boolean;
- $depth?: number;
- $interactive?: boolean;
- $withBranch?: boolean;
- $highlighted?: boolean;
- $highlightRgb?: string;
-}>`
- --highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
- align-items: center;
- background: ${({ $active }) =>
- $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'};
- border-radius: 4px;
- display: grid;
- gap: 0;
- grid-template-columns: auto;
- justify-content: center;
- height: 28px;
- padding: 0;
- position: relative;
- text-decoration: none;
- transition: background-color 0.14s ease;
- animation: ${({ $highlighted }) =>
- $highlighted
- ? 'objectAppearRow 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
- : 'none'};
- transform-origin: left center;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- grid-template-columns: ${({ $withBranch }) =>
- $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
- justify-content: stretch;
- padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
- }
-
- &:hover {
- background: ${({ $active, $interactive }) =>
- $active || $interactive
- ? VISUAL_TOKENS.background.transparent.medium
- : 'transparent'};
- }
-
- @keyframes objectAppearRow {
- 0% {
- background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
- box-shadow:
- 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
- 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
- opacity: 0;
- transform: translateX(-32px) translateY(-6px) scale(0.6);
- }
- 16% {
- background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
- box-shadow:
- 0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
- 0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
- opacity: 1;
- transform: translateX(0) translateY(0) scale(1.18);
- }
- 32% {
- background: rgba(var(--highlight-rgb, 237, 95, 0), 0.42);
- box-shadow:
- 0 0 0 12px rgba(var(--highlight-rgb, 237, 95, 0), 0.24),
- 0 10px 22px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.38);
- transform: translateX(0) scale(0.97);
- }
- 50% {
- background: rgba(var(--highlight-rgb, 237, 95, 0), 0.28);
- box-shadow:
- 0 0 0 18px rgba(var(--highlight-rgb, 237, 95, 0), 0.12),
- 0 6px 16px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.22);
- transform: translateX(0) scale(1.02);
- }
- 72% {
- background: rgba(var(--highlight-rgb, 237, 95, 0), 0.16);
- box-shadow:
- 0 0 0 22px rgba(var(--highlight-rgb, 237, 95, 0), 0),
- 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
- transform: translateX(0) scale(1);
- }
- 100% {
- background: ${VISUAL_TOKENS.background.transparent.medium};
- box-shadow:
- 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
- 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
- transform: translateX(0) scale(1);
- }
- }
-`;
-
-const SidebarItemRowLink = styled.a<{
- $active?: boolean;
- $depth?: number;
- $interactive?: boolean;
- $withBranch?: boolean;
-}>`
- align-items: center;
- background: ${({ $active }) =>
- $active ? VISUAL_TOKENS.background.transparent.medium : 'transparent'};
- border-radius: 4px;
- display: grid;
- gap: 0;
- grid-template-columns: auto;
- justify-content: center;
- height: 28px;
- padding: 0;
- position: relative;
- text-decoration: none;
- transition: background-color 0.14s ease;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- grid-template-columns: ${({ $withBranch }) =>
- $withBranch ? '9px minmax(0, 1fr) auto' : 'minmax(0, 1fr) auto'};
- justify-content: stretch;
- padding: 0 2px 0 ${({ $depth = 0 }) => `${$depth === 0 ? 4 : 11}px`};
- }
-
- &:hover {
- background: ${({ $active, $interactive }) =>
- $active || $interactive
- ? VISUAL_TOKENS.background.transparent.medium
- : 'transparent'};
- }
-`;
-
-const SidebarItemText = styled.div`
- display: none;
- min-width: 0;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- align-items: center;
- display: flex;
- gap: 2px;
- }
-`;
-
-const SidebarItemLabel = styled.span<{ $active?: boolean }>`
- color: ${({ $active }) => ($active ? COLORS.text : COLORS.textSecondary)};
- display: none;
- font-family: ${APP_FONT};
- font-size: 13px;
- font-weight: ${theme.font.weight.medium};
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- display: block;
- }
-`;
-
-const SidebarItemMeta = styled.span`
- color: ${COLORS.textLight};
- display: none;
- font-family: ${APP_FONT};
- font-size: 13px;
- font-weight: ${theme.font.weight.medium};
- line-height: 1.4;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- display: block;
- }
-`;
-
-const SidebarChevron = styled.div<{ $expanded?: boolean }>`
- color: ${COLORS.textTertiary};
- display: none;
- transform: rotate(${({ $expanded }) => ($expanded ? '0deg' : '-90deg')});
- transition: transform 0.16s ease;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- display: flex;
- }
-`;
-
-const SidebarChildStack = styled.div`
- display: grid;
- gap: 2px;
- position: relative;
-`;
-
-const BranchLine = styled.div`
- background: ${COLORS.borderStrong};
- bottom: 14px;
- left: 11px;
- position: absolute;
- top: 0;
- width: 1px;
-`;
-
-const SidebarBranchCell = styled.div<{ $isLastChild?: boolean }>`
- align-self: stretch;
- position: relative;
- width: 9px;
-
- &::before {
- background: ${COLORS.borderStrong};
- content: '';
- inset: 0 88.89% 0 0;
- opacity: ${({ $isLastChild }) => ($isLastChild ? 0 : 1)};
- position: absolute;
- }
-
- &::after {
- border-bottom: 1px solid ${COLORS.borderStrong};
- border-left: 1px solid ${COLORS.borderStrong};
- border-radius: 0 0 0 4px;
- content: '';
- inset: 0 0 45.83% 0;
- position: absolute;
- }
-`;
-
-const SidebarRowMain = styled.div<{ $withBranch?: boolean }>`
- align-items: center;
- display: flex;
- gap: 8px;
- min-width: 0;
- padding-left: ${({ $withBranch }) => ($withBranch ? '4px' : '0')};
-`;
-
-type SidebarItemProps = {
- collapsible?: boolean;
- expanded?: boolean;
- depth?: number;
- highlightedItemId?: string;
- interactive?: boolean;
- isLastChild?: boolean;
- item: SidebarItemDef;
- onSelect?: (label: string) => void;
- onToggleExpanded?: () => void;
- selectedLabel?: string;
-};
-
-export function SidebarItem({
- collapsible = false,
- expanded = false,
- depth = 0,
- highlightedItemId,
- interactive = true,
- isLastChild = false,
- item,
- onSelect,
- onToggleExpanded,
- selectedLabel,
-}: SidebarItemProps) {
- const showBranch = depth > 0;
- const rowSelectable = interactive && item.href === undefined && !collapsible;
- const rowInteractive =
- rowSelectable || item.href !== undefined || (interactive && collapsible);
- const rowActive =
- rowSelectable &&
- selectedLabel !== undefined &&
- item.label === selectedLabel;
- const rowHighlighted = highlightedItemId === item.id;
- const childItems = item.children ?? [];
- const highlightRgb = getSidebarIconToneRgb(item.icon);
- const rowContent = (
- <>
- {showBranch ? : null}
-
- {renderPreviewIcon(item.icon, rowHighlighted)}
-
- {item.label}
- {item.meta ? · {item.meta} : null}
-
-
- {item.showChevron || (item.children && item.children.length > 0) ? (
-
-
-
- ) : null}
- >
- );
-
- return (
- <>
- {item.href ? (
-
- {rowContent}
-
- ) : (
- onSelect?.(item.label)
- : undefined
- }
- style={{ cursor: rowInteractive ? 'pointer' : 'default' }}
- >
- {rowContent}
-
- )}
- {childItems.length > 0 && (!collapsible || expanded) ? (
-
-
- {childItems.map((child, index) => (
-
- ))}
-
- ) : null}
- >
- );
-}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFavorites.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFavorites.tsx
new file mode 100644
index 0000000000..fee700838d
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFavorites.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarItemDef } from '../types';
+import { SidebarRailItem } from './SidebarRailItem';
+
+const FavoritesRail = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ row-gap: 6px;
+`;
+
+type SidebarRailFavoritesProps = {
+ favoritesNav: SidebarItemDef[];
+ highlightedItemId?: string;
+ onSelectPageItem: (itemId: string) => void;
+ selectedItemId: string;
+};
+
+export function SidebarRailFavorites({
+ favoritesNav,
+ highlightedItemId,
+ onSelectPageItem,
+ selectedItemId,
+}: SidebarRailFavoritesProps) {
+ return (
+
+ {favoritesNav.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFolder.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFolder.tsx
new file mode 100644
index 0000000000..cbd354995c
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailFolder.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarFolderDef } from '../types';
+import { SidebarRailItem } from './SidebarRailItem';
+
+const RailChildStack = styled.div`
+ display: flex;
+ flex-direction: column;
+ row-gap: 4px;
+ padding-top: 4px;
+`;
+
+type SidebarRailFolderProps = {
+ collapsedOpenFolderId?: string;
+ folder: SidebarFolderDef;
+ highlightedItemId?: string;
+ onSelectFolder: (folderId: string, firstChildItemId: string) => void;
+ onSelectPageItem: (itemId: string, folderId?: string) => void;
+ selectedItemId: string;
+};
+
+export function SidebarRailFolder({
+ collapsedOpenFolderId,
+ folder,
+ highlightedItemId,
+ onSelectFolder,
+ onSelectPageItem,
+ selectedItemId,
+}: SidebarRailFolderProps) {
+ const firstChild = folder.items[0];
+ const isExpanded = collapsedOpenFolderId === folder.id;
+ const hasActiveChild = folder.items.some(
+ (item) => item.id === selectedItemId,
+ );
+
+ return (
+
+ onSelectFolder(folder.id, firstChild.id)
+ : undefined
+ }
+ />
+ {isExpanded ? (
+
+ {folder.items.map((item) => (
+ onSelectPageItem(itemId, folder.id)}
+ />
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailItem.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailItem.tsx
new file mode 100644
index 0000000000..a975eca73f
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailItem.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import { css } from '@linaria/core';
+import { styled } from '@linaria/react';
+
+import type { SidebarItemDef } from '../types';
+import { renderPreviewIcon } from '../Shared/components/PreviewIcon';
+import { getSidebarIconToneRgb } from '../Shared/utils/get-sidebar-icon-tone-rgb';
+
+const SIDEBAR_ACTIVE_BACKGROUND = 'rgba(0, 0, 0, 0.04)';
+
+const railItemSharedStyles = css`
+ align-items: center;
+ border-radius: 10px;
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 auto;
+ justify-content: center;
+ padding-bottom: 0;
+ padding-right: 0;
+ padding-top: 0;
+ transition: background-color 0.14s ease;
+ width: 100%;
+
+ &:hover {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ }
+`;
+
+const RailItemButton = styled.button<{
+ $active?: boolean;
+ $child?: boolean;
+ $highlightRgb?: string;
+ $highlighted?: boolean;
+}>`
+ --highlight-rgb: ${({ $highlightRgb }) => $highlightRgb ?? '237, 95, 0'};
+ animation: ${({ $highlighted }) =>
+ $highlighted
+ ? 'sidebarRailItemAppear 1800ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
+ : 'none'};
+ appearance: none;
+ background: ${({ $active }) =>
+ $active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
+ border: 0;
+ height: ${({ $child }) => ($child ? '32px' : '36px')};
+ padding-left: ${({ $child }) => ($child ? '8px' : '0')};
+
+ @keyframes sidebarRailItemAppear {
+ 0% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ box-shadow:
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ opacity: 0;
+ transform: translateY(8px) scale(0.72);
+ }
+ 16% {
+ background: rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
+ box-shadow:
+ 0 0 0 6px rgba(var(--highlight-rgb, 237, 95, 0), 0.4),
+ 0 12px 28px -6px rgba(var(--highlight-rgb, 237, 95, 0), 0.55);
+ opacity: 1;
+ transform: translateY(0) scale(1.14);
+ }
+ 100% {
+ background: ${SIDEBAR_ACTIVE_BACKGROUND};
+ box-shadow:
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0),
+ 0 0 0 0 rgba(var(--highlight-rgb, 237, 95, 0), 0);
+ transform: translateY(0) scale(1);
+ }
+ }
+`;
+
+const RailItemLink = styled.a<{ $active?: boolean; $child?: boolean }>`
+ background: ${({ $active }) =>
+ $active ? SIDEBAR_ACTIVE_BACKGROUND : 'transparent'};
+ height: ${({ $child }) => ($child ? '32px' : '36px')};
+ padding-left: ${({ $child }) => ($child ? '8px' : '0')};
+ text-decoration: none;
+`;
+
+type SidebarRailDisplayItem = Pick<
+ SidebarItemDef,
+ 'href' | 'icon' | 'id' | 'label'
+>;
+
+type SidebarRailItemProps = {
+ active?: boolean;
+ child?: boolean;
+ highlighted?: boolean;
+ item: SidebarRailDisplayItem;
+ onSelect?: (itemId: string) => void;
+};
+
+export function SidebarRailItem({
+ active = false,
+ child = false,
+ highlighted = false,
+ item,
+ onSelect,
+}: SidebarRailItemProps) {
+ const highlightRgb = getSidebarIconToneRgb(item.icon);
+ const icon = renderPreviewIcon(item.icon, highlighted);
+
+ if (item.href) {
+ return (
+
+ {icon}
+
+ );
+ }
+
+ return (
+ onSelect(item.id) : undefined}
+ title={item.label}
+ type="button"
+ >
+ {icon}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailWorkspace.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailWorkspace.tsx
new file mode 100644
index 0000000000..eb2c3ef94c
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarRailWorkspace.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarEntry } from '../types';
+import { isFolder } from './is-folder';
+import { SidebarRailFolder } from './SidebarRailFolder';
+import { SidebarRailItem } from './SidebarRailItem';
+
+const WorkspaceRail = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+`;
+
+const WorkspaceRailScroll = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ row-gap: 6px;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+type SidebarRailWorkspaceProps = {
+ collapsedOpenFolderId?: string;
+ highlightedItemId?: string;
+ onSelectFolder: (folderId: string, firstChildItemId: string) => void;
+ onSelectPageItem: (itemId: string, folderId?: string) => void;
+ selectedItemId: string;
+ workspaceNav: SidebarEntry[];
+};
+
+export function SidebarRailWorkspace({
+ collapsedOpenFolderId,
+ highlightedItemId,
+ onSelectFolder,
+ onSelectPageItem,
+ selectedItemId,
+ workspaceNav,
+}: SidebarRailWorkspaceProps) {
+ return (
+
+
+ {workspaceNav.map((entry) => {
+ if (!isFolder(entry)) {
+ return (
+ onSelectPageItem(itemId)}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/SidebarWorkspace.tsx b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarWorkspace.tsx
new file mode 100644
index 0000000000..e7a6a7eebb
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/SidebarWorkspace.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import type { SidebarEntry } from '../types';
+import { APP_FONT, COLORS } from '../Shared/utils/app-preview-theme';
+import { isFolder } from './is-folder';
+import { SidebarDesktopFolder } from './SidebarDesktopFolder';
+import { SidebarDesktopItem } from './SidebarDesktopItem';
+
+const WorkspaceSection = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+`;
+
+const WorkspaceScroll = styled.div`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ row-gap: 2px;
+ scrollbar-width: none;
+
+ padding-bottom: 8px;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+const WorkspaceLabelRow = styled.div`
+ align-items: center;
+ display: flex;
+ height: 28px;
+ padding-left: 4px;
+ padding-right: 2px;
+`;
+
+const WorkspaceLabel = styled.span`
+ color: ${COLORS.textLight};
+ font-family: ${APP_FONT};
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+`;
+
+type SidebarWorkspaceProps = {
+ highlightedItemId?: string;
+ onSelectPageItem: (itemId: string) => void;
+ onToggleFolder: (folderId: string) => void;
+ openFolderIds: string[];
+ selectedItemId: string;
+ workspaceNav: SidebarEntry[];
+};
+
+export function SidebarWorkspace({
+ highlightedItemId,
+ onSelectPageItem,
+ onToggleFolder,
+ openFolderIds,
+ selectedItemId,
+ workspaceNav,
+}: SidebarWorkspaceProps) {
+ return (
+
+
+ Workspace
+
+
+ {workspaceNav.map((entry) => {
+ if (isFolder(entry)) {
+ return (
+ onToggleFolder(entry.id)}
+ selectedItemId={selectedItemId}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/build-sidebar-index.ts b/packages/twenty-website/src/sections/AppPreview/Shell/build-sidebar-index.ts
new file mode 100644
index 0000000000..e75392d054
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/build-sidebar-index.ts
@@ -0,0 +1,105 @@
+import type {
+ SidebarEntry,
+ SidebarFolderDef,
+ SidebarItemDef,
+ SidebarPageItemDef,
+} from '../types';
+
+import { isFolder } from './is-folder';
+
+type SidebarIndex = {
+ foldersById: Map;
+ itemsById: Map;
+ pageItemsById: Map;
+ parentFolderIdsByItemId: Map;
+};
+
+function isPageItem(item: SidebarItemDef): item is SidebarPageItemDef {
+ return item.page !== undefined;
+}
+
+function registerUniqueId(
+ seenIds: Set,
+ id: string,
+ context: string,
+): void {
+ if (seenIds.has(id)) {
+ throw new Error(
+ `AppPreview sidebar contains a duplicate id "${id}" in ${context}.`,
+ );
+ }
+
+ seenIds.add(id);
+}
+
+function indexEntries(
+ entries: SidebarEntry[],
+ context: string,
+ seenIds: Set,
+): SidebarIndex {
+ const foldersById = new Map();
+ const itemsById = new Map();
+ const pageItemsById = new Map();
+ const parentFolderIdsByItemId = new Map();
+
+ for (const entry of entries) {
+ if (isFolder(entry)) {
+ registerUniqueId(seenIds, entry.id, `${context} folder`);
+ foldersById.set(entry.id, entry);
+
+ for (const item of entry.items) {
+ registerUniqueId(
+ seenIds,
+ item.id,
+ `${context} folder "${entry.id}" child item`,
+ );
+ itemsById.set(item.id, item);
+ pageItemsById.set(item.id, item);
+ parentFolderIdsByItemId.set(item.id, entry.id);
+ }
+
+ continue;
+ }
+
+ registerUniqueId(seenIds, entry.id, `${context} item`);
+ itemsById.set(entry.id, entry);
+
+ if (isPageItem(entry)) {
+ pageItemsById.set(entry.id, entry);
+ }
+ }
+
+ return {
+ foldersById,
+ itemsById,
+ pageItemsById,
+ parentFolderIdsByItemId,
+ };
+}
+
+type SidebarIndexSource = {
+ favorites: SidebarItemDef[];
+ workspace: SidebarEntry[];
+};
+
+export function buildSidebarIndex({
+ favorites,
+ workspace,
+}: SidebarIndexSource): SidebarIndex {
+ const seenIds = new Set();
+ const favoriteIndex = indexEntries(favorites, 'favorites', seenIds);
+ const workspaceIndex = indexEntries(workspace, 'workspace', seenIds);
+
+ return {
+ foldersById: workspaceIndex.foldersById,
+ itemsById: new Map([
+ ...favoriteIndex.itemsById.entries(),
+ ...workspaceIndex.itemsById.entries(),
+ ]),
+ pageItemsById: new Map([
+ ...favoriteIndex.pageItemsById.entries(),
+ ...workspaceIndex.pageItemsById.entries(),
+ ]),
+ parentFolderIdsByItemId: workspaceIndex.parentFolderIdsByItemId,
+ };
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/find-active-item.ts b/packages/twenty-website/src/sections/AppPreview/Shell/find-active-item.ts
deleted file mode 100644
index e72f171c33..0000000000
--- a/packages/twenty-website/src/sections/AppPreview/Shell/find-active-item.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { SidebarEntry, SidebarItemDef } from '../types';
-import type { PageDefaults } from '../Data/page-defaults';
-import { normalizePage } from '../Data/normalize-page';
-
-import { isFolder } from './is-folder';
-
-function hasRenderablePage(
- item: SidebarItemDef,
- pageDefaults: PageDefaults,
-): boolean {
- return normalizePage(item, pageDefaults) !== null;
-}
-
-export function findActiveItem(
- entries: SidebarEntry[],
- activeLabel: string,
- pageDefaults: PageDefaults,
-): SidebarItemDef | undefined {
- for (const entry of entries) {
- if (isFolder(entry)) {
- for (const child of entry.items) {
- if (child.label === activeLabel) {
- return child;
- }
- }
-
- continue;
- }
-
- if (entry.children) {
- for (const child of entry.children) {
- if (child.label === activeLabel) {
- return child;
- }
- }
- }
-
- if (entry.label === activeLabel) {
- if (
- !hasRenderablePage(entry, pageDefaults) &&
- entry.children &&
- entry.children.length > 0
- ) {
- const firstChildWithRenderablePage = entry.children.find((child) =>
- hasRenderablePage(child, pageDefaults),
- );
-
- if (firstChildWithRenderablePage) {
- return firstChildWithRenderablePage;
- }
- }
-
- return entry;
- }
- }
-
- return undefined;
-}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/find-containing-folder-id.ts b/packages/twenty-website/src/sections/AppPreview/Shell/find-containing-folder-id.ts
deleted file mode 100644
index 7baacd595b..0000000000
--- a/packages/twenty-website/src/sections/AppPreview/Shell/find-containing-folder-id.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { SidebarEntry } from '../types';
-
-import { isFolder } from './is-folder';
-
-export function findContainingFolderId(
- entries: SidebarEntry[],
- label: string,
-): string | undefined {
- for (const entry of entries) {
- if (!isFolder(entry)) {
- continue;
- }
-
- if (
- entry.items.some(
- (item) =>
- item.label === label ||
- item.children?.some((child) => child.label === label) === true,
- )
- ) {
- return entry.id;
- }
- }
-
- return undefined;
-}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-crm-scenario.ts b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-crm-scenario.ts
new file mode 100644
index 0000000000..071f14ce90
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-crm-scenario.ts
@@ -0,0 +1,110 @@
+import { useTimeoutRegistry } from '@/lib/react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { COMPANIES_ITEM_ID, CRM_OBJECT_SEQUENCE } from '../Data/rocket-object';
+import type { SidebarEntry } from '../types';
+
+const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id);
+const COMPLETED_REVEALED_OBJECT_IDS = [
+ ...COMPLETED_CREATED_OBJECT_IDS,
+ COMPANIES_ITEM_ID,
+];
+const HIGHLIGHT_RESET_DELAY_MS = 2000;
+
+export const COMPLETED_ACTIVE_OBJECT_ID = CRM_OBJECT_SEQUENCE.at(-1)!.id;
+
+type AppPreviewCrmScenario = {
+ handleJumpToConversationEnd: () => void;
+ handleObjectCreated: (id: string) => void;
+ handleScenarioReset: () => void;
+ highlightedItemId: string | null;
+ revealedObjectIds: string[];
+ workspaceEntries: SidebarEntry[];
+};
+
+export function useAppPreviewCrmScenario(
+ baseWorkspaceEntries: SidebarEntry[],
+): AppPreviewCrmScenario {
+ const timeoutRegistry = useTimeoutRegistry();
+ const [createdObjectIds, setCreatedObjectIds] = useState([]);
+ const [revealedObjectIds, setRevealedObjectIds] = useState([]);
+ const [highlightedItemId, setHighlightedItemId] = useState(
+ null,
+ );
+
+ const workspaceEntries = useMemo(() => {
+ if (createdObjectIds.length === 0) {
+ return baseWorkspaceEntries;
+ }
+
+ const prependedEntries = [...createdObjectIds]
+ .reverse()
+ .map(
+ (id) =>
+ CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id)
+ ?.sidebarItem,
+ )
+ .filter(
+ (entry): entry is NonNullable => entry !== undefined,
+ );
+
+ return [...prependedEntries, ...baseWorkspaceEntries];
+ }, [baseWorkspaceEntries, createdObjectIds]);
+
+ const handleObjectCreated = useCallback((id: string) => {
+ setRevealedObjectIds((current) =>
+ current.includes(id) ? current : [...current, id],
+ );
+ setHighlightedItemId(id);
+
+ if (id === COMPANIES_ITEM_ID) {
+ return;
+ }
+
+ const sequenceEntry = CRM_OBJECT_SEQUENCE.find(
+ (candidate) => candidate.id === id,
+ );
+
+ if (!sequenceEntry) {
+ throw new Error(
+ `AppPreview CRM scenario does not support object id "${id}".`,
+ );
+ }
+
+ setCreatedObjectIds((current) =>
+ current.includes(id) ? current : [...current, id],
+ );
+ }, []);
+
+ const handleScenarioReset = useCallback(() => {
+ setCreatedObjectIds([]);
+ setRevealedObjectIds([]);
+ setHighlightedItemId(null);
+ }, []);
+
+ const handleJumpToConversationEnd = useCallback(() => {
+ setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
+ setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
+ setHighlightedItemId(null);
+ }, []);
+
+ useEffect(() => {
+ if (highlightedItemId === null) {
+ return undefined;
+ }
+
+ return timeoutRegistry.schedule(
+ () => setHighlightedItemId(null),
+ HIGHLIGHT_RESET_DELAY_MS,
+ );
+ }, [highlightedItemId, timeoutRegistry]);
+
+ return {
+ handleJumpToConversationEnd,
+ handleObjectCreated,
+ handleScenarioReset,
+ highlightedItemId,
+ revealedObjectIds,
+ workspaceEntries,
+ };
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-experience.ts b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-experience.ts
new file mode 100644
index 0000000000..0baa5de206
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-experience.ts
@@ -0,0 +1,118 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import type { AppPreviewConfig, SidebarEntry, SidebarItemDef } from '../types';
+import { COMPANIES_ITEM_ID } from '../Data/rocket-object';
+import {
+ COMPLETED_ACTIVE_OBJECT_ID,
+ useAppPreviewCrmScenario,
+} from './use-app-preview-crm-scenario';
+import { useAppPreviewNavigation } from './use-app-preview-navigation';
+
+function buildItemListSignature(items: SidebarItemDef[]) {
+ return items.map((item) => item.id).join('|');
+}
+
+function buildWorkspaceSignature(entries: SidebarEntry[]) {
+ return entries
+ .map((entry) =>
+ 'items' in entry
+ ? `folder:${entry.id}[${entry.items.map((item) => item.id).join(',')}]`
+ : `item:${entry.id}`,
+ )
+ .join('|');
+}
+
+function buildSidebarConfigResetKey(visual: AppPreviewConfig) {
+ return [
+ visual.sidebar.initialActiveItemId,
+ visual.sidebar.initialOpenFolderIds.join('|'),
+ buildItemListSignature(visual.sidebar.favorites),
+ buildWorkspaceSignature(visual.sidebar.workspace),
+ ].join('::');
+}
+
+export function useAppPreviewExperience(visual: AppPreviewConfig) {
+ const sidebarConfigResetKey = useMemo(
+ () => buildSidebarConfigResetKey(visual),
+ [visual],
+ );
+ const previousSidebarConfigResetKeyRef = useRef(sidebarConfigResetKey);
+ const scenario = useAppPreviewCrmScenario(visual.sidebar.workspace);
+ const navigation = useAppPreviewNavigation({
+ defaultViewbarActions: visual.defaultViewbarActions,
+ sidebar: {
+ favorites: visual.sidebar.favorites,
+ initialActiveItemId: visual.sidebar.initialActiveItemId,
+ initialOpenFolderIds: visual.sidebar.initialOpenFolderIds,
+ workspace: scenario.workspaceEntries,
+ },
+ });
+ const {
+ handleJumpToConversationEnd: completeScenario,
+ handleObjectCreated: revealObject,
+ handleScenarioReset,
+ highlightedItemId,
+ revealedObjectIds,
+ } = scenario;
+ const { canSelectPageItem, resetNavigation, selectPageItem } = navigation;
+ const [pendingPageItemSelectionId, setPendingPageItemSelectionId] = useState<
+ string | null
+ >(null);
+
+ useEffect(() => {
+ if (
+ pendingPageItemSelectionId === null ||
+ !canSelectPageItem(pendingPageItemSelectionId)
+ ) {
+ return;
+ }
+
+ selectPageItem(pendingPageItemSelectionId);
+ setPendingPageItemSelectionId(null);
+ }, [canSelectPageItem, pendingPageItemSelectionId, selectPageItem]);
+
+ useEffect(() => {
+ if (previousSidebarConfigResetKeyRef.current === sidebarConfigResetKey) {
+ return;
+ }
+
+ previousSidebarConfigResetKeyRef.current = sidebarConfigResetKey;
+ setPendingPageItemSelectionId(null);
+ handleScenarioReset();
+ resetNavigation();
+ }, [handleScenarioReset, resetNavigation, sidebarConfigResetKey]);
+
+ const handleObjectCreated = useCallback(
+ (id: string) => {
+ revealObject(id);
+
+ if (id === COMPANIES_ITEM_ID) {
+ selectPageItem(COMPANIES_ITEM_ID);
+ return;
+ }
+
+ setPendingPageItemSelectionId(id);
+ },
+ [revealObject, selectPageItem],
+ );
+
+ const handleChatReset = useCallback(() => {
+ setPendingPageItemSelectionId(null);
+ handleScenarioReset();
+ resetNavigation();
+ }, [handleScenarioReset, resetNavigation]);
+
+ const handleJumpToConversationEnd = useCallback(() => {
+ completeScenario();
+ setPendingPageItemSelectionId(COMPLETED_ACTIVE_OBJECT_ID);
+ }, [completeScenario]);
+
+ return {
+ ...navigation,
+ handleChatReset,
+ handleJumpToConversationEnd,
+ handleObjectCreated,
+ highlightedItemId,
+ revealedObjectIds,
+ };
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-navigation.ts b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-navigation.ts
new file mode 100644
index 0000000000..a9aac58ce6
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-navigation.ts
@@ -0,0 +1,217 @@
+import { useCallback, useMemo, useState } from 'react';
+
+import { normalizePage } from '../Data/normalize-page';
+import type {
+ AppPreviewConfig,
+ AppPreviewSidebarConfig,
+ PageDefinition,
+ SidebarEntry,
+ SidebarItemDef,
+ SidebarPageItemDef,
+} from '../types';
+import { buildSidebarIndex } from './build-sidebar-index';
+
+const DEFAULT_TABLE_WIDTH = 1700;
+
+function getInitialOpenFolderIds(
+ sidebar: AppPreviewSidebarConfig,
+ activeItemId: string,
+ workspaceEntries: SidebarEntry[],
+) {
+ const navigationIndex = buildSidebarIndex({
+ favorites: sidebar.favorites,
+ workspace: workspaceEntries,
+ });
+
+ const defaultActiveItem = navigationIndex.pageItemsById.get(activeItemId);
+
+ if (!defaultActiveItem) {
+ throw new Error(
+ `AppPreviewConfig references unknown initial active item id "${activeItemId}".`,
+ );
+ }
+
+ for (const folderId of sidebar.initialOpenFolderIds) {
+ if (!navigationIndex.foldersById.has(folderId)) {
+ throw new Error(
+ `AppPreviewConfig references unknown initial open folder id "${folderId}".`,
+ );
+ }
+ }
+
+ const openFolderIds = new Set(sidebar.initialOpenFolderIds);
+ const parentFolderId =
+ navigationIndex.parentFolderIdsByItemId.get(activeItemId);
+
+ if (parentFolderId) {
+ openFolderIds.add(parentFolderId);
+ }
+
+ return [...openFolderIds];
+}
+
+type AppPreviewNavigationConfig = Pick<
+ AppPreviewConfig,
+ 'defaultViewbarActions'
+> & {
+ sidebar: Pick<
+ AppPreviewSidebarConfig,
+ 'favorites' | 'initialActiveItemId' | 'initialOpenFolderIds'
+ > & {
+ workspace: SidebarEntry[];
+ };
+};
+
+type AppPreviewNavigationState = {
+ activeItem: SidebarPageItemDef;
+ activeItemId: string;
+ activeItemLabel: string;
+ activePage: PageDefinition;
+ canSelectPageItem: (itemId: string) => boolean;
+ favorites: SidebarItemDef[];
+ openFolderIds: string[];
+ resetNavigation: () => void;
+ selectPageItem: (itemId: string) => void;
+ toggleFolder: (folderId: string) => void;
+ workspaceEntries: SidebarEntry[];
+};
+
+function getVisibleSidebarItems(items: SidebarItemDef[]) {
+ return items.filter((item) => !item.hidden);
+}
+
+function getVisibleSidebarEntries(entries: SidebarEntry[]): SidebarEntry[] {
+ return entries.reduce((visibleEntries, entry) => {
+ if ('items' in entry) {
+ const visibleItems = entry.items.filter((item) => !item.hidden);
+
+ if (visibleItems.length === 0) {
+ return visibleEntries;
+ }
+
+ visibleEntries.push({ ...entry, items: visibleItems });
+ return visibleEntries;
+ }
+
+ if (!entry.hidden) {
+ visibleEntries.push(entry);
+ }
+
+ return visibleEntries;
+ }, []);
+}
+
+export function useAppPreviewNavigation(
+ visual: AppPreviewNavigationConfig,
+): AppPreviewNavigationState {
+ const defaultActiveItemId = visual.sidebar.initialActiveItemId;
+
+ const navigationIndex = useMemo(
+ () =>
+ buildSidebarIndex({
+ favorites: visual.sidebar.favorites,
+ workspace: visual.sidebar.workspace,
+ }),
+ [visual.sidebar.favorites, visual.sidebar.workspace],
+ );
+
+ const [activeItemId, setActiveItemId] = useState(defaultActiveItemId);
+ const [openFolderIds, setOpenFolderIds] = useState(() =>
+ getInitialOpenFolderIds(
+ visual.sidebar,
+ defaultActiveItemId,
+ visual.sidebar.workspace,
+ ),
+ );
+
+ const pageDefaults = useMemo(
+ () => ({
+ defaultActions: visual.defaultViewbarActions,
+ defaultTableWidth: DEFAULT_TABLE_WIDTH,
+ }),
+ [visual.defaultViewbarActions],
+ );
+
+ const activeItem = navigationIndex.pageItemsById.get(activeItemId);
+
+ if (!activeItem) {
+ throw new Error(
+ `AppPreview attempted to select unknown page item id "${activeItemId}".`,
+ );
+ }
+
+ const activePage = normalizePage(activeItem, pageDefaults);
+
+ const canSelectPageItem = useCallback(
+ (itemId: string) => navigationIndex.pageItemsById.has(itemId),
+ [navigationIndex.pageItemsById],
+ );
+
+ const selectPageItem = useCallback(
+ (itemId: string) => {
+ if (!canSelectPageItem(itemId)) {
+ throw new Error(
+ `AppPreview attempted to select unknown page item id "${itemId}".`,
+ );
+ }
+
+ setActiveItemId(itemId);
+
+ const containingFolderId =
+ navigationIndex.parentFolderIdsByItemId.get(itemId);
+
+ if (!containingFolderId) {
+ return;
+ }
+
+ setOpenFolderIds((current) =>
+ current.includes(containingFolderId)
+ ? current
+ : [...current, containingFolderId],
+ );
+ },
+ [canSelectPageItem, navigationIndex.parentFolderIdsByItemId],
+ );
+
+ const toggleFolder = useCallback(
+ (folderId: string) => {
+ if (!navigationIndex.foldersById.has(folderId)) {
+ throw new Error(
+ `AppPreview attempted to toggle unknown folder id "${folderId}".`,
+ );
+ }
+
+ setOpenFolderIds((current) =>
+ current.includes(folderId)
+ ? current.filter((id) => id !== folderId)
+ : [...current, folderId],
+ );
+ },
+ [navigationIndex.foldersById],
+ );
+
+ const resetNavigation = useCallback(() => {
+ setActiveItemId(defaultActiveItemId);
+ setOpenFolderIds(
+ getInitialOpenFolderIds(
+ visual.sidebar,
+ defaultActiveItemId,
+ visual.sidebar.workspace,
+ ),
+ );
+ }, [defaultActiveItemId, visual.sidebar]);
+
+ return {
+ activeItem,
+ activeItemId,
+ activeItemLabel: activeItem.label,
+ activePage,
+ canSelectPageItem,
+ favorites: getVisibleSidebarItems(visual.sidebar.favorites),
+ openFolderIds,
+ resetNavigation,
+ selectPageItem,
+ toggleFolder,
+ workspaceEntries: getVisibleSidebarEntries(visual.sidebar.workspace),
+ };
+}
diff --git a/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-state.ts b/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-state.ts
deleted file mode 100644
index 69681f9bb0..0000000000
--- a/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-state.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { useTimeoutRegistry } from '@/lib/react';
-import { useCallback, useEffect, useMemo, useState } from 'react';
-
-import type { SidebarEntry, AppPreviewConfig } from '../types';
-import { normalizePage } from '../Data/normalize-page';
-import { findActiveItem } from './find-active-item';
-import { findContainingFolderId } from './find-containing-folder-id';
-import { isFolder } from './is-folder';
-import {
- COMPANIES_ITEM_ID,
- COMPANIES_ITEM_LABEL,
- CRM_OBJECT_SEQUENCE,
-} from '../Data/rocket-object';
-
-const DEFAULT_TABLE_WIDTH = 1700;
-const COMPLETED_CREATED_OBJECT_IDS = CRM_OBJECT_SEQUENCE.map(({ id }) => id);
-const COMPLETED_REVEALED_OBJECT_IDS = [
- ...COMPLETED_CREATED_OBJECT_IDS,
- COMPANIES_ITEM_ID,
-];
-const COMPLETED_ACTIVE_OBJECT_LABEL =
- CRM_OBJECT_SEQUENCE.at(-1)?.label ?? COMPANIES_ITEM_LABEL;
-const HIGHLIGHT_RESET_DELAY_MS = 2000;
-
-export function useAppPreviewState(visual: AppPreviewConfig) {
- const timeoutRegistry = useTimeoutRegistry();
- const defaultActiveLabel =
- visual.favoritesNav?.find((item) => item.active)?.label ??
- visual.workspaceNav.find((entry) => !isFolder(entry) && entry.active)
- ?.label ??
- visual.workspaceNav[0]?.label ??
- '';
-
- const [activeLabel, setActiveLabel] = useState(defaultActiveLabel);
- const [createdObjectIds, setCreatedObjectIds] = useState([]);
- const [revealedObjectIds, setRevealedObjectIds] = useState([]);
- const [highlightedItemId, setHighlightedItemId] = useState(
- null,
- );
- const [openFolderIds, setOpenFolderIds] = useState(() => {
- const activeFolderId = findContainingFolderId(
- visual.workspaceNav,
- defaultActiveLabel,
- );
-
- return visual.workspaceNav.flatMap((entry) => {
- if (!isFolder(entry)) {
- return [];
- }
-
- if (entry.defaultOpen || entry.id === activeFolderId) {
- return [entry.id];
- }
-
- return [];
- });
- });
-
- const pageDefaults = useMemo(
- () => ({
- defaultActions: visual.actions ?? [],
- defaultTableWidth: visual.tableWidth ?? DEFAULT_TABLE_WIDTH,
- }),
- [visual.actions, visual.tableWidth],
- );
-
- const workspaceNav = useMemo(() => {
- if (createdObjectIds.length === 0) {
- return visual.workspaceNav;
- }
-
- const prepended = [...createdObjectIds]
- .reverse()
- .map(
- (id) =>
- CRM_OBJECT_SEQUENCE.find((entry) => entry.id === id)?.sidebarItem,
- )
- .filter((item): item is NonNullable => item !== undefined);
-
- return [...prepended, ...visual.workspaceNav];
- }, [createdObjectIds, visual.workspaceNav]);
-
- const handleObjectCreated = useCallback((id: string) => {
- setRevealedObjectIds((current) =>
- current.includes(id) ? current : [...current, id],
- );
-
- if (id === COMPANIES_ITEM_ID) {
- setActiveLabel(COMPANIES_ITEM_LABEL);
- setHighlightedItemId(COMPANIES_ITEM_ID);
- return;
- }
-
- const entry = CRM_OBJECT_SEQUENCE.find((candidate) => candidate.id === id);
-
- if (!entry) {
- return;
- }
-
- setCreatedObjectIds((current) =>
- current.includes(id) ? current : [...current, id],
- );
- setActiveLabel(entry.label);
- setHighlightedItemId(entry.id);
- }, []);
-
- const handleChatReset = useCallback(() => {
- setCreatedObjectIds([]);
- setRevealedObjectIds([]);
- setHighlightedItemId(null);
- setActiveLabel(defaultActiveLabel);
- }, [defaultActiveLabel]);
-
- const handleJumpToConversationEnd = useCallback(() => {
- setCreatedObjectIds(COMPLETED_CREATED_OBJECT_IDS);
- setRevealedObjectIds(COMPLETED_REVEALED_OBJECT_IDS);
- setHighlightedItemId(null);
- setActiveLabel(COMPLETED_ACTIVE_OBJECT_LABEL);
- }, []);
-
- useEffect(() => {
- if (highlightedItemId === null) {
- return undefined;
- }
-
- return timeoutRegistry.schedule(
- () => setHighlightedItemId(null),
- HIGHLIGHT_RESET_DELAY_MS,
- );
- }, [highlightedItemId, timeoutRegistry]);
-
- const activeItem = useMemo(
- () =>
- (visual.favoritesNav
- ? findActiveItem(visual.favoritesNav, activeLabel, pageDefaults)
- : undefined) ?? findActiveItem(workspaceNav, activeLabel, pageDefaults),
- [activeLabel, pageDefaults, visual.favoritesNav, workspaceNav],
- );
-
- const activePage = useMemo(
- () => (activeItem ? normalizePage(activeItem, pageDefaults) : null),
- [activeItem, pageDefaults],
- );
-
- const handleSelectLabel = useCallback(
- (label: string) => {
- setActiveLabel(label);
-
- const containingFolderId = findContainingFolderId(workspaceNav, label);
-
- if (!containingFolderId) {
- return;
- }
-
- setOpenFolderIds((current) =>
- current.includes(containingFolderId)
- ? current
- : [...current, containingFolderId],
- );
- },
- [workspaceNav],
- );
-
- const handleToggleFolder = useCallback((folderId: string) => {
- setOpenFolderIds((current) =>
- current.includes(folderId)
- ? current.filter((id) => id !== folderId)
- : [...current, folderId],
- );
- }, []);
-
- return {
- activeItem,
- activeLabel,
- activePage,
- handleChatReset,
- handleJumpToConversationEnd,
- handleObjectCreated,
- handleSelectLabel,
- handleToggleFolder,
- highlightedItemId,
- openFolderIds,
- revealedObjectIds,
- workspaceNav,
- };
-}
diff --git a/packages/twenty-website/src/sections/AppPreview/Terminal/Conversation/components/AssistantResponse.tsx b/packages/twenty-website/src/sections/AppPreview/Terminal/Conversation/components/AssistantResponse.tsx
index 36eb4340a5..298fac0e0f 100644
--- a/packages/twenty-website/src/sections/AppPreview/Terminal/Conversation/components/AssistantResponse.tsx
+++ b/packages/twenty-website/src/sections/AppPreview/Terminal/Conversation/components/AssistantResponse.tsx
@@ -1,7 +1,8 @@
'use client';
+import { useLatestRef } from '@/lib/react';
import { styled } from '@linaria/react';
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { ASSISTANT_RESPONSE_STREAMING_STAGES } from '../utils/assistant-response-streaming-stages';
import { buildAssistantResponseSegments } from './AssistantResponseSegments';
@@ -48,16 +49,29 @@ export const AssistantResponse = ({
onObjectCreated,
onChatFinished,
}: AssistantResponseProps) => {
+ const onObjectCreatedRef = useLatestRef(onObjectCreated);
const { createStageCompletionHandler, hasReachedStage, stage } =
useAssistantResponseStage({
instantComplete,
onChatFinished,
});
- const objectCreationHandler = instantComplete ? undefined : onObjectCreated;
+ const objectCreationHandler = useCallback(
+ (id: string) => {
+ if (instantComplete) {
+ return;
+ }
+
+ onObjectCreatedRef.current?.(id);
+ },
+ [instantComplete, onObjectCreatedRef],
+ );
const segmentsByStage = useMemo(
- () => buildAssistantResponseSegments(objectCreationHandler),
- [objectCreationHandler],
+ () =>
+ buildAssistantResponseSegments(
+ instantComplete ? undefined : objectCreationHandler,
+ ),
+ [instantComplete, objectCreationHandler],
);
return (
diff --git a/packages/twenty-website/src/sections/AppPreview/Terminal/Terminal.tsx b/packages/twenty-website/src/sections/AppPreview/Terminal/Terminal.tsx
index 4c249b2442..1f085c6e0a 100644
--- a/packages/twenty-website/src/sections/AppPreview/Terminal/Terminal.tsx
+++ b/packages/twenty-website/src/sections/AppPreview/Terminal/Terminal.tsx
@@ -13,6 +13,8 @@ import { useTerminalWindowLayout } from './hooks/use-terminal-window-layout';
import type { TerminalToggleValue } from './types/terminal-toggle-types';
import { TERMINAL_TOKENS } from './utils/terminal-tokens';
+const HIDE_BELOW_VIEWPORT_WIDTH = 1350;
+
const Shell = styled.div<{
$isDragging: boolean;
$isResizing: boolean;
@@ -37,6 +39,10 @@ const Shell = styled.div<{
top: 0;
touch-action: none;
+ @media (max-width: ${HIDE_BELOW_VIEWPORT_WIDTH - 0.02}px) {
+ display: none;
+ }
+
@media (min-width: ${theme.breakpoints.md}px) {
box-shadow: ${({ $isDragging, $isResizing }) =>
$isDragging || $isResizing
diff --git a/packages/twenty-website/src/sections/AppPreview/Terminal/__tests__/terminal-window-geometry.test.ts b/packages/twenty-website/src/sections/AppPreview/Terminal/__tests__/terminal-window-geometry.test.ts
index c6457965b7..37f8c017b5 100644
--- a/packages/twenty-website/src/sections/AppPreview/Terminal/__tests__/terminal-window-geometry.test.ts
+++ b/packages/twenty-website/src/sections/AppPreview/Terminal/__tests__/terminal-window-geometry.test.ts
@@ -14,7 +14,7 @@ describe('terminal-window-geometry', () => {
it('places the initial terminal at the desktop bottom-right anchor', () => {
expect(getInitialTerminalLayout({ width: 1000, height: 700 })).toEqual({
- position: { left: 620, top: 384 },
+ position: { left: 768, top: 448 },
size: { width: 380, height: 220 },
});
});
diff --git a/packages/twenty-website/src/sections/AppPreview/Terminal/utils/terminal-window-geometry.ts b/packages/twenty-website/src/sections/AppPreview/Terminal/utils/terminal-window-geometry.ts
index bd017e2512..56beff35e9 100644
--- a/packages/twenty-website/src/sections/AppPreview/Terminal/utils/terminal-window-geometry.ts
+++ b/packages/twenty-website/src/sections/AppPreview/Terminal/utils/terminal-window-geometry.ts
@@ -12,7 +12,8 @@ export const TERMINAL_EDITOR_WIDTH = 720;
export const TERMINAL_EDITOR_HEIGHT = 480;
export const TERMINAL_MIN_WIDTH = 300;
export const TERMINAL_MIN_HEIGHT = 200;
-export const TERMINAL_INITIAL_BOTTOM_OFFSET = 96;
+export const TERMINAL_INITIAL_BOTTOM_OFFSET = 32;
+export const TERMINAL_INITIAL_RIGHT_OFFSET = 148;
export const TERMINAL_EDGE_GAP = 0;
export const TERMINAL_MOBILE_PARENT_BREAKPOINT = 640;
export const TERMINAL_MOBILE_OFFSET_X = 16;
@@ -113,7 +114,10 @@ export const getInitialTerminalLayout = (
return {
position: {
- left: Math.max(0, bounds.width - size.width),
+ left: Math.max(
+ 0,
+ bounds.width - size.width + TERMINAL_INITIAL_RIGHT_OFFSET,
+ ),
top: Math.max(
0,
bounds.height - size.height - TERMINAL_INITIAL_BOTTOM_OFFSET,
diff --git a/packages/twenty-website/src/sections/AppPreview/app-preview.data.ts b/packages/twenty-website/src/sections/AppPreview/app-preview.data.ts
new file mode 100644
index 0000000000..83af239e71
--- /dev/null
+++ b/packages/twenty-website/src/sections/AppPreview/app-preview.data.ts
@@ -0,0 +1,1770 @@
+import type {
+ AppPreviewConfig,
+ DashboardData,
+ DashboardPageDefinition,
+ KanbanPageDefinition,
+ TablePageDefinition,
+} from './types';
+import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
+
+const PEOPLE_AVATAR_URLS = SHARED_PEOPLE_AVATAR_URLS;
+
+const SALES_DASHBOARD_DATA: DashboardData = {
+ kpis: [
+ {
+ id: 'pipeline',
+ title: 'Pipeline',
+ value: '$12.9M',
+ trend: { direction: 'up', value: '+8%' },
+ },
+ {
+ id: 'won-this-quarter',
+ title: 'Won this quarter',
+ value: '$2.4M',
+ trend: { direction: 'up', value: '+12%' },
+ },
+ {
+ id: 'win-rate',
+ title: 'Win rate',
+ value: '38%',
+ trend: { direction: 'down', value: '-3%' },
+ },
+ ],
+ lineChart: {
+ title: 'ARR over time',
+ labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'],
+ values: [3.1, 3.8, 3.5, 4.6, 5.4, 6.1, 7.2],
+ },
+ barChart: {
+ title: 'Deals by stage',
+ bars: [
+ { label: 'New', value: 12 },
+ { label: 'Screening', value: 9 },
+ { label: 'Meeting', value: 7 },
+ { label: 'Proposal', value: 5 },
+ { label: 'Customer', value: 4 },
+ ],
+ },
+ donutChart: {
+ title: 'By industry',
+ centerValue: '24',
+ centerLabel: 'deals',
+ slices: [
+ { label: 'AI', value: 8, color: '#8da4ef' },
+ { label: 'Fintech', value: 6, color: '#be93e4' },
+ { label: 'SaaS', value: 5, color: '#53b9ab' },
+ { label: 'Other', value: 5, color: '#ec9455' },
+ ],
+ },
+};
+
+const SALES_DASHBOARD_PAGE: DashboardPageDefinition = {
+ type: 'dashboard',
+ header: {
+ title: 'Sales Dashboard',
+ },
+ dashboard: SALES_DASHBOARD_DATA,
+};
+
+const OPPORTUNITY_KANBAN_PAGE: KanbanPageDefinition = {
+ type: 'kanban',
+ header: {
+ title: 'Best leads',
+ },
+ lanes: [
+ {
+ id: 'new',
+ label: 'New',
+ tone: 'pink',
+ cards: [
+ {
+ id: 'anthropic-enterprise-expansion',
+ title: 'Enterprise Expansion',
+ amount: '$500,000',
+ company: {
+ type: 'entity',
+ name: 'Anthropic',
+ domain: 'anthropic.com',
+ },
+ accountOwner: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ rating: 2,
+ date: 'Jul 1, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Dario Amodei',
+ shortLabel: 'D',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ recordId: 'OPP-1',
+ },
+ {
+ id: 'figma-ai-prototyping',
+ title: 'AI Prototyping',
+ amount: '$3,500,000',
+ company: { type: 'entity', name: 'Figma', domain: 'figma.com' },
+ accountOwner: {
+ type: 'person',
+ name: 'Dylan Field',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ rating: 2,
+ date: 'Jul 12, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Dylan Field',
+ shortLabel: 'D',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ recordId: 'OPP-2',
+ },
+ ],
+ },
+ {
+ id: 'screening',
+ label: 'Screening',
+ tone: 'purple',
+ cards: [
+ {
+ id: 'notion-workspace-consolidation',
+ title: 'Workspace Consolidation',
+ amount: '$750,000',
+ company: { type: 'entity', name: 'Notion', domain: 'notion.com' },
+ accountOwner: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ rating: 4,
+ date: 'Jul 8, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ shortLabel: 'I',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ recordId: 'OPP-3',
+ },
+ ],
+ },
+ {
+ id: 'meeting',
+ label: 'Meeting',
+ tone: 'blue',
+ cards: [
+ {
+ id: 'github-copilot-rollout',
+ title: 'Copilot Rollout',
+ amount: '$900,000',
+ company: { type: 'entity', name: 'Github', domain: 'github.com' },
+ accountOwner: {
+ type: 'person',
+ name: 'Chris Wanstrath',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath,
+ },
+ rating: 3,
+ date: 'Jul 14, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Thomas Dohmke',
+ shortLabel: 'T',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
+ },
+ recordId: 'OPP-4',
+ },
+ {
+ id: 'stripe-billing-expansion',
+ title: 'Billing Expansion',
+ amount: '$1,800,000',
+ company: { type: 'entity', name: 'Stripe', domain: 'stripe.com' },
+ accountOwner: {
+ type: 'person',
+ name: 'Patrick Collison',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ rating: 5,
+ date: 'Jul 17, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Patrick Collison',
+ shortLabel: 'P',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ recordId: 'OPP-5',
+ },
+ {
+ id: 'airbnb-host-ops',
+ title: 'Host Ops',
+ amount: '$4,200,000',
+ company: { type: 'entity', name: 'Airbnb', domain: 'airbnb.com' },
+ accountOwner: {
+ type: 'person',
+ name: 'Joe Gebbia',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia,
+ },
+ rating: 3,
+ date: 'Jul 15, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Brian Chesky',
+ shortLabel: 'B',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
+ },
+ recordId: 'OPP-6',
+ },
+ ],
+ },
+ {
+ id: 'proposal',
+ label: 'Proposal',
+ tone: 'gray',
+ cards: [],
+ },
+ {
+ id: 'customer',
+ label: 'Customer',
+ tone: 'green',
+ cards: [
+ {
+ id: 'mailchimp-lifecycle-campaigns',
+ title: 'Lifecycle Campaigns',
+ amount: '$1,250,000',
+ company: {
+ type: 'entity',
+ name: 'Mailchimp',
+ domain: 'mailchimp.com',
+ },
+ accountOwner: {
+ type: 'person',
+ name: 'Ben Chestnut',
+ tone: 'amber',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
+ },
+ rating: 4,
+ date: 'Jul 23, 2023',
+ mainContact: {
+ type: 'person',
+ name: 'Rania Succar',
+ shortLabel: 'R',
+ tone: 'amber',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura,
+ },
+ recordId: 'OPP-7',
+ },
+ ],
+ },
+ ],
+};
+
+function createTablePage({
+ title,
+ count,
+ columns,
+ rows,
+}: {
+ title: string;
+ count: number;
+ columns: TablePageDefinition['columns'];
+ rows: TablePageDefinition['rows'];
+}): TablePageDefinition {
+ return {
+ type: 'table',
+ header: {
+ title,
+ count,
+ },
+ columns,
+ rows,
+ };
+}
+
+export const APP_PREVIEW_DATA: { visual: AppPreviewConfig } = {
+ visual: {
+ defaultViewbarActions: ['Filter', 'Sort', 'Options'],
+ sidebar: {
+ favorites: [
+ {
+ id: 'sales-dashboard',
+ label: 'Sales Dashboard',
+ icon: { kind: 'avatar', label: 'S', tone: 'amber', shape: 'circle' },
+ meta: 'Dashboard',
+ page: SALES_DASHBOARD_PAGE,
+ },
+ ],
+ initialActiveItemId: 'companies',
+ initialOpenFolderIds: [],
+ workspace: [
+ {
+ id: 'companies',
+ label: 'Companies',
+ icon: { kind: 'tabler', name: 'buildingSkyscraper', tone: 'blue' },
+ page: createTablePage({
+ title: 'All Companies',
+ count: 9,
+ columns: [
+ {
+ id: 'company',
+ label: 'Companies',
+ width: 180,
+ isFirstColumn: true,
+ },
+ { id: 'url', label: 'Url', width: 140 },
+ { id: 'createdBy', label: 'Created By', width: 150 },
+ { id: 'address', label: 'Address', width: 120 },
+ { id: 'accountOwner', label: 'Account Owner', width: 150 },
+ { id: 'icp', label: 'ICP', width: 80 },
+ { id: 'arr', label: 'ARR', width: 120, align: 'right' },
+ { id: 'linkedin', label: 'Linkedin', width: 96 },
+ { id: 'industry', label: 'Industry', width: 96 },
+ { id: 'mainContact', label: 'Main contact', width: 120 },
+ {
+ id: 'employees',
+ label: 'Employees',
+ width: 120,
+ align: 'right',
+ },
+ { id: 'opportunities', label: 'Opportunities', width: 122 },
+ { id: 'added', label: 'Added', width: 120 },
+ ],
+ rows: [
+ {
+ id: 'anthropic',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Anthropic',
+ domain: 'anthropic.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'anthropic.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ address: { type: 'text', value: '18 Rue De Navarin' },
+ accountOwner: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$500,000' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'anthropic',
+ },
+ industry: { type: 'select', value: 'AI Research' },
+ mainContact: {
+ type: 'person',
+ name: 'Dario Amodei',
+ shortLabel: 'D',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ employees: { type: 'number', value: '612' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Enterprise Expansion',
+ shortLabel: 'E',
+ tone: 'blue',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 1, 2023' },
+ },
+ },
+ {
+ id: 'linkedin',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Linkedin',
+ domain: 'linkedin.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'linkedin.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Reid Hoffman',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.reidHoffman,
+ },
+ address: { type: 'text', value: '1226 Moises Causeway' },
+ accountOwner: {
+ type: 'person',
+ name: 'Ryan Roslansky',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
+ },
+ icp: { type: 'boolean', value: false },
+ arr: { type: 'currency', value: '$1,000,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'linkedin' },
+ industry: {
+ type: 'select',
+ value: 'Professional Networking',
+ },
+ mainContact: {
+ type: 'person',
+ name: 'Ryan Roslansky',
+ shortLabel: 'R',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
+ },
+ employees: { type: 'number', value: '19,300' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Talent Outreach',
+ shortLabel: 'T',
+ tone: 'purple',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 3, 2023' },
+ },
+ },
+ {
+ id: 'slack',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Slack',
+ domain: 'slack.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'slack.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Stewart Butterfield',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
+ },
+ address: { type: 'text', value: '1316 Dameon Mountain' },
+ accountOwner: {
+ type: 'person',
+ name: 'Stewart Butterfield',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$2,300,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'slack' },
+ industry: { type: 'select', value: 'Collaboration Software' },
+ mainContact: {
+ type: 'person',
+ name: 'Lidiane Jones',
+ shortLabel: 'LJ',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.anonymousIndira,
+ },
+ employees: { type: 'number', value: '4,500' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Workspace Renewal',
+ shortLabel: 'W',
+ tone: 'teal',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 5, 2023' },
+ },
+ },
+ {
+ id: 'notion',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Notion',
+ domain: 'notion.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'notion.com' },
+ createdBy: {
+ type: 'person',
+ name: 'API - Key name',
+ tone: 'gray',
+ kind: 'api',
+ shortLabel: 'API',
+ },
+ address: { type: 'text', value: '1162 Sammy Creek' },
+ accountOwner: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ icp: { type: 'boolean', value: false },
+ arr: { type: 'currency', value: '$750,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'notion' },
+ industry: { type: 'select', value: 'Productivity Software' },
+ mainContact: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ shortLabel: 'I',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ employees: { type: 'number', value: '620' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Workspace Consolidation',
+ shortLabel: 'W',
+ tone: 'gray',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 8, 2023' },
+ },
+ },
+ {
+ id: 'figma',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Figma',
+ domain: 'figma.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'figma.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Workflow name',
+ tone: 'gray',
+ kind: 'workflow',
+ shortLabel: 'WF',
+ },
+ address: { type: 'text', value: '110 Oswald Junction' },
+ accountOwner: {
+ type: 'person',
+ name: 'Dylan Field',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$3,500,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'figma' },
+ industry: { type: 'select', value: 'Design Tools' },
+ mainContact: {
+ type: 'person',
+ name: 'Dylan Field',
+ shortLabel: 'D',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ employees: { type: 'number', value: '1,300' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'AI Prototyping',
+ shortLabel: 'AI',
+ tone: 'purple',
+ },
+ { name: 'Design Ops', shortLabel: 'D', tone: 'teal' },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 12, 2023' },
+ },
+ },
+ {
+ id: 'github',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Github',
+ domain: 'github.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'github.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Chris Wanstrath',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.chrisWanstrath,
+ },
+ address: { type: 'text', value: '3891 Ranchview Drive' },
+ accountOwner: {
+ type: 'person',
+ name: 'Thomas Dohmke',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$900,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'github' },
+ industry: { type: 'select', value: 'Developer Platform' },
+ mainContact: {
+ type: 'person',
+ name: 'Thomas Dohmke',
+ shortLabel: 'T',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.thomasDohmke,
+ },
+ employees: { type: 'number', value: '3,800' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Copilot Rollout',
+ shortLabel: 'C',
+ tone: 'blue',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 14, 2023' },
+ },
+ },
+ {
+ id: 'airbnb',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Airbnb',
+ domain: 'airbnb.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'airbnb.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Joe Gebbia',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.joeGebbia,
+ },
+ address: { type: 'text', value: '4517 Washington Avenue' },
+ accountOwner: {
+ type: 'person',
+ name: 'Brian Chesky',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$4,200,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'airbnb' },
+ industry: { type: 'select', value: 'Travel' },
+ mainContact: {
+ type: 'person',
+ name: 'Brian Chesky',
+ shortLabel: 'B',
+ tone: 'pink',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.brianChesky,
+ },
+ employees: { type: 'number', value: '6,900' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ { name: 'Host Ops', shortLabel: 'H', tone: 'pink' },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 15, 2023' },
+ },
+ },
+ {
+ id: 'stripe',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Stripe',
+ domain: 'stripe.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'stripe.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Patrick Collison',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ address: { type: 'text', value: '2118 Thornridge Circle' },
+ accountOwner: {
+ type: 'person',
+ name: 'Patrick Collison',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$1,800,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'stripe' },
+ industry: { type: 'select', value: 'Payments' },
+ mainContact: {
+ type: 'person',
+ name: 'Patrick Collison',
+ shortLabel: 'P',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ employees: { type: 'number', value: '7,400' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Billing Expansion',
+ shortLabel: 'B',
+ tone: 'purple',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 17, 2023' },
+ },
+ },
+ {
+ id: 'sequoia',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Sequoia',
+ domain: 'sequoia.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'sequoia.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Roelof Botha',
+ tone: 'green',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
+ },
+ address: { type: 'text', value: '1316 Dameon Mountain' },
+ accountOwner: {
+ type: 'person',
+ name: 'Roelof Botha',
+ tone: 'green',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
+ },
+ icp: { type: 'boolean', value: false },
+ arr: { type: 'currency', value: '$6,000,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'sequoia' },
+ industry: { type: 'select', value: 'Venture Capital' },
+ mainContact: {
+ type: 'person',
+ name: 'Roelof Botha',
+ shortLabel: 'R',
+ tone: 'green',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.roelofBotha,
+ },
+ employees: { type: 'number', value: '1,100' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ { name: 'Fund Ops', shortLabel: 'F', tone: 'green' },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 20, 2023' },
+ },
+ },
+ {
+ id: 'segment',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Segment',
+ domain: 'segment.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'segment.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Peter Reinhardt',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
+ },
+ address: { type: 'text', value: '8502 Preston Rd. East' },
+ accountOwner: {
+ type: 'person',
+ name: 'Peter Reinhardt',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$2,750,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'segment' },
+ industry: { type: 'select', value: 'Customer Data' },
+ mainContact: {
+ type: 'person',
+ name: 'Peter Reinhardt',
+ shortLabel: 'P',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.peterReinhardt,
+ },
+ employees: { type: 'number', value: '1,550' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Warehouse Rollout',
+ shortLabel: 'W',
+ tone: 'teal',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 21, 2023' },
+ },
+ },
+ {
+ id: 'mailchimp',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Mailchimp',
+ domain: 'mailchimp.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'mailchimp.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Ben Chestnut',
+ tone: 'amber',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
+ },
+ address: { type: 'text', value: '3517 W. Gray St.' },
+ accountOwner: {
+ type: 'person',
+ name: 'Ben Chestnut',
+ tone: 'amber',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.benChestnut,
+ },
+ icp: { type: 'boolean', value: false },
+ arr: { type: 'currency', value: '$1,250,000' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'mailchimp',
+ },
+ industry: { type: 'select', value: 'Marketing Automation' },
+ mainContact: {
+ type: 'person',
+ name: 'Rania Succar',
+ shortLabel: 'R',
+ tone: 'amber',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.anonymousLaura,
+ },
+ employees: { type: 'number', value: '1,900' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Lifecycle Campaigns',
+ shortLabel: 'L',
+ tone: 'amber',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 23, 2023' },
+ },
+ },
+ {
+ id: 'accel',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Accel',
+ domain: 'accel.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'accel.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Ray Damm',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.rayDamm,
+ },
+ address: { type: 'text', value: '4140 Parker Rd.' },
+ accountOwner: {
+ type: 'person',
+ name: 'Ping Li',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.pingLi,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$5,800,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'accel' },
+ industry: { type: 'select', value: 'Venture Capital' },
+ mainContact: {
+ type: 'person',
+ name: 'Ping Li',
+ shortLabel: 'P',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.pingLi,
+ },
+ employees: { type: 'number', value: '540' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Portfolio Sync',
+ shortLabel: 'P',
+ tone: 'purple',
+ },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 24, 2023' },
+ },
+ },
+ {
+ id: 'founders-fund',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Founders Fund',
+ domain: 'foundersfund.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'foundersfund.com' },
+ createdBy: {
+ type: 'person',
+ name: 'System',
+ tone: 'gray',
+ kind: 'system',
+ shortLabel: 'SYS',
+ },
+ address: { type: 'text', value: '2715 Ash Dr. San Jose' },
+ accountOwner: {
+ type: 'person',
+ name: 'Peter Thiel',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.peterThiel,
+ },
+ icp: { type: 'boolean', value: true },
+ arr: { type: 'currency', value: '$2,100,000' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'foundersfund',
+ },
+ industry: { type: 'select', value: 'Private Equity' },
+ mainContact: {
+ type: 'person',
+ name: 'Peter Thiel',
+ shortLabel: 'P',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.peterThiel,
+ },
+ employees: { type: 'number', value: '734' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ { name: 'Fundraising', shortLabel: 'F', tone: 'gray' },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 25, 2023' },
+ },
+ },
+ {
+ id: 'google',
+ cells: {
+ company: {
+ type: 'entity',
+ name: 'Google',
+ domain: 'google.com',
+ },
+ url: { type: 'link', kind: 'url', value: 'google.com' },
+ createdBy: {
+ type: 'person',
+ name: 'Sundar Pichai',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
+ },
+ address: { type: 'text', value: '4140 Parker Rd.' },
+ accountOwner: {
+ type: 'person',
+ name: 'Sundar Pichai',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
+ },
+ icp: { type: 'boolean', value: false },
+ arr: { type: 'currency', value: '$7,500,000' },
+ linkedin: { type: 'link', kind: 'social', value: 'google' },
+ industry: { type: 'select', value: 'Computer Software' },
+ mainContact: {
+ type: 'person',
+ name: 'Sundar Pichai',
+ shortLabel: 'S',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.sundarPichai,
+ },
+ employees: { type: 'number', value: '734' },
+ opportunities: {
+ type: 'relation',
+ items: [
+ {
+ name: 'Google AI and Data Solutions',
+ shortLabel: 'G',
+ tone: 'teal',
+ },
+ { name: 'Relation 2', shortLabel: 'L', tone: 'teal' },
+ { name: 'Relation 3', shortLabel: 'L', tone: 'teal' },
+ ],
+ },
+ added: { type: 'text', value: 'Jul 1, 2023 2:25 pm' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'people',
+ label: 'People',
+ icon: { kind: 'tabler', name: 'user', tone: 'blue' },
+ page: createTablePage({
+ title: 'All People',
+ count: 5,
+ columns: [
+ { id: 'name', label: 'Name', width: 180, isFirstColumn: true },
+ { id: 'company', label: 'Company', width: 160 },
+ { id: 'email', label: 'Email', width: 200 },
+ { id: 'phone', label: 'Phone', width: 160 },
+ { id: 'jobTitle', label: 'Job Title', width: 160 },
+ { id: 'city', label: 'City', width: 120 },
+ { id: 'linkedin', label: 'Linkedin', width: 140 },
+ { id: 'added', label: 'Added', width: 160 },
+ ],
+ rows: [
+ {
+ id: 'dario-amodei',
+ cells: {
+ name: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ company: {
+ type: 'entity',
+ name: 'Anthropic',
+ domain: 'anthropic.com',
+ },
+ email: {
+ type: 'link',
+ kind: 'email',
+ value: 'dario@anthropic.com',
+ },
+ phone: {
+ type: 'link',
+ kind: 'phone',
+ value: '+1 415 555 0101',
+ },
+ jobTitle: { type: 'text', value: 'CEO' },
+ city: { type: 'text', value: 'San Francisco' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'dario-amodei',
+ },
+ added: { type: 'text', value: 'Jul 3, 2023' },
+ },
+ },
+ {
+ id: 'ryan-roslansky',
+ cells: {
+ name: {
+ type: 'person',
+ name: 'Ryan Roslansky',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ryanRoslansky,
+ },
+ company: {
+ type: 'entity',
+ name: 'Linkedin',
+ domain: 'linkedin.com',
+ },
+ email: {
+ type: 'link',
+ kind: 'email',
+ value: 'ryan@linkedin.com',
+ },
+ phone: {
+ type: 'link',
+ kind: 'phone',
+ value: '+1 650 555 0134',
+ },
+ jobTitle: { type: 'text', value: 'CEO' },
+ city: { type: 'text', value: 'Sunnyvale' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'ryanroslansky',
+ },
+ added: { type: 'text', value: 'Jul 28, 2023' },
+ },
+ },
+ {
+ id: 'stewart-butterfield',
+ cells: {
+ name: {
+ type: 'person',
+ name: 'Stewart Butterfield',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
+ },
+ company: {
+ type: 'entity',
+ name: 'Slack',
+ domain: 'slack.com',
+ },
+ email: {
+ type: 'link',
+ kind: 'email',
+ value: 'stewart@slack.com',
+ },
+ phone: {
+ type: 'link',
+ kind: 'phone',
+ value: '+1 415 555 0142',
+ },
+ jobTitle: { type: 'text', value: 'Co-founder' },
+ city: { type: 'text', value: 'San Francisco' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'stewart-butterfield',
+ },
+ added: { type: 'text', value: 'Jul 18, 2023' },
+ },
+ },
+ {
+ id: 'ivan-zhao',
+ cells: {
+ name: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ company: {
+ type: 'entity',
+ name: 'Notion',
+ domain: 'notion.com',
+ },
+ email: {
+ type: 'link',
+ kind: 'email',
+ value: 'ivan@notion.com',
+ },
+ phone: {
+ type: 'link',
+ kind: 'phone',
+ value: '+1 628 555 0186',
+ },
+ jobTitle: { type: 'text', value: 'CEO' },
+ city: { type: 'text', value: 'San Francisco' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'ivanhzhao',
+ },
+ added: { type: 'text', value: 'Jul 8, 2023' },
+ },
+ },
+ {
+ id: 'dylan-field',
+ cells: {
+ name: {
+ type: 'person',
+ name: 'Dylan Field',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ company: {
+ type: 'entity',
+ name: 'Figma',
+ domain: 'figma.com',
+ },
+ email: {
+ type: 'link',
+ kind: 'email',
+ value: 'dylan@figma.com',
+ },
+ phone: {
+ type: 'link',
+ kind: 'phone',
+ value: '+1 415 555 0128',
+ },
+ jobTitle: { type: 'text', value: 'CEO' },
+ city: { type: 'text', value: 'San Francisco' },
+ linkedin: {
+ type: 'link',
+ kind: 'social',
+ value: 'dylanfield',
+ },
+ added: { type: 'text', value: 'Jul 12, 2023' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'opportunities',
+ label: 'Opportunities',
+ icon: { kind: 'tabler', name: 'targetArrow', tone: 'red' },
+ page: OPPORTUNITY_KANBAN_PAGE,
+ },
+ {
+ id: 'tasks',
+ label: 'Tasks',
+ icon: { kind: 'tabler', name: 'checkbox', tone: 'teal' },
+ page: createTablePage({
+ title: 'All Tasks',
+ count: 2,
+ columns: [
+ { id: 'title', label: 'Title', width: 220, isFirstColumn: true },
+ { id: 'assignee', label: 'Assignee', width: 160 },
+ { id: 'dueDate', label: 'Due Date', width: 160 },
+ { id: 'relatedTo', label: 'Related To', width: 160 },
+ { id: 'status', label: 'Status', width: 140 },
+ ],
+ rows: [
+ {
+ id: 'send-nda',
+ cells: {
+ title: {
+ type: 'text',
+ value: 'Send NDA',
+ shortLabel: 'S',
+ tone: 'teal',
+ },
+ assignee: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ dueDate: { type: 'text', value: 'Oct 25, 2023' },
+ relatedTo: {
+ type: 'entity',
+ name: 'Anthropic',
+ domain: 'anthropic.com',
+ },
+ status: { type: 'select', value: 'To Do' },
+ },
+ },
+ {
+ id: 'review-proposal',
+ cells: {
+ title: {
+ type: 'text',
+ value: 'Review proposal',
+ shortLabel: 'R',
+ tone: 'teal',
+ },
+ assignee: {
+ type: 'person',
+ name: 'Stewart Butterfield',
+ tone: 'teal',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.stewartButterfield,
+ },
+ dueDate: { type: 'text', value: 'Oct 28, 2023' },
+ relatedTo: {
+ type: 'entity',
+ name: 'Slack',
+ domain: 'slack.com',
+ },
+ status: {
+ type: 'select',
+ color: 'blue',
+ value: 'In Progress',
+ },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'notes',
+ label: 'Notes',
+ icon: { kind: 'tabler', name: 'notes', tone: 'teal' },
+ page: createTablePage({
+ title: 'All Notes',
+ count: 2,
+ columns: [
+ { id: 'title', label: 'Title', width: 240, isFirstColumn: true },
+ { id: 'createdBy', label: 'Created By', width: 160 },
+ { id: 'relatedTo', label: 'Related To', width: 160 },
+ { id: 'added', label: 'Added', width: 180 },
+ ],
+ rows: [
+ {
+ id: 'discovery-call',
+ cells: {
+ title: {
+ type: 'text',
+ value: 'Discovery call notes',
+ shortLabel: 'D',
+ tone: 'green',
+ },
+ createdBy: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ relatedTo: {
+ type: 'entity',
+ name: 'Notion',
+ domain: 'notion.com',
+ },
+ added: { type: 'text', value: 'Sep 2, 2023' },
+ },
+ },
+ {
+ id: 'design-system-meeting',
+ cells: {
+ title: {
+ type: 'text',
+ value: 'Design system meeting',
+ shortLabel: 'D',
+ tone: 'green',
+ },
+ createdBy: {
+ type: 'person',
+ name: 'Dylan Field',
+ tone: 'purple',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.dylanField,
+ },
+ relatedTo: {
+ type: 'entity',
+ name: 'Figma',
+ domain: 'figma.com',
+ },
+ added: { type: 'text', value: 'Oct 18, 2023' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'dashboards',
+ label: 'Dashboards',
+ icon: { kind: 'tabler', name: 'layoutDashboard', tone: 'gray' },
+ page: createTablePage({
+ title: 'All Dashboards',
+ count: 2,
+ columns: [
+ { id: 'name', label: 'Name', width: 240, isFirstColumn: true },
+ { id: 'createdBy', label: 'Created By', width: 160 },
+ { id: 'added', label: 'Last Edited', width: 160 },
+ ],
+ rows: [
+ {
+ id: 'sales-dashboard',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Sales Dashboard',
+ shortLabel: 'S',
+ tone: 'amber',
+ },
+ createdBy: {
+ type: 'person',
+ name: 'Dario Amodei',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.darioAmodei,
+ },
+ added: { type: 'text', value: 'Oct 24, 2023' },
+ },
+ },
+ {
+ id: 'pipeline-health',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Pipeline Health',
+ shortLabel: 'P',
+ tone: 'blue',
+ },
+ createdBy: {
+ type: 'person',
+ name: 'Patrick Collison',
+ tone: 'blue',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.patrickCollison,
+ },
+ added: { type: 'text', value: 'Oct 19, 2023' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'workflows',
+ label: 'Workflows',
+ icon: { kind: 'tabler', name: 'settingsAutomation', tone: 'orange' },
+ items: [
+ {
+ id: 'workflow-create-company-when-adding-a-new-person',
+ label: 'Create company when adding a new person',
+ icon: {
+ color: '#451E11',
+ kind: 'avatar',
+ label: 'C',
+ 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: 'Create company when adding a new person',
+ },
+ },
+ },
+ {
+ id: 'workflow-send-email-sequence',
+ hidden: true,
+ 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: 195,
+ width: 190,
+ label: 'Action',
+ title: 'Iterator',
+ iconName: 'repeat',
+ },
+ {
+ id: 'send-email',
+ x: 640,
+ y: 268,
+ 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: 656, y: 214, text: 'loop' },
+ { x: 515, y: 276, text: 'completed' },
+ ],
+ plusNode: { x: 515, y: 308 },
+ },
+ },
+ {
+ id: 'workflow-list',
+ label: 'All Workflows',
+ icon: {
+ kind: 'tabler',
+ name: 'settingsAutomation',
+ tone: 'gray',
+ },
+ page: createTablePage({
+ title: 'All Workflows',
+ count: 2,
+ columns: [
+ {
+ id: 'name',
+ label: 'Name',
+ width: 240,
+ isFirstColumn: true,
+ },
+ { id: 'status', label: 'Status', width: 140 },
+ { id: 'lastRun', label: 'Last Run', width: 200 },
+ ],
+ rows: [
+ {
+ id: 'create-company-when-adding-a-new-person',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Create company when adding a new person',
+ shortLabel: 'C',
+ tone: 'orange',
+ },
+ status: {
+ type: 'select',
+ color: 'green',
+ value: 'Active',
+ },
+ lastRun: { type: 'text', value: 'Oct 24, 2023 10:00 am' },
+ },
+ },
+ {
+ id: 'nurture',
+ cells: {
+ name: {
+ type: 'text',
+ value: 'Nurture Sequence',
+ shortLabel: 'N',
+ tone: 'amber',
+ },
+ status: { type: 'select', value: 'Inactive' },
+ lastRun: { type: 'text', value: 'Oct 20, 2023 3:15 pm' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'workflow-runs',
+ label: 'Workflows runs',
+ icon: { kind: 'tabler', name: 'playerPlay', tone: 'gray' },
+ page: createTablePage({
+ title: 'All Runs',
+ count: 2,
+ columns: [
+ {
+ id: 'runId',
+ label: 'Run ID',
+ width: 160,
+ isFirstColumn: true,
+ },
+ { id: 'workflow', label: 'Workflow', width: 200 },
+ { id: 'status', label: 'Status', width: 120 },
+ { id: 'startedAt', label: 'Started At', width: 200 },
+ { id: 'duration', label: 'Duration', width: 120 },
+ ],
+ rows: [
+ {
+ id: 'run-12345',
+ cells: {
+ runId: {
+ type: 'text',
+ value: 'run_12345',
+ shortLabel: 'R',
+ tone: 'amber',
+ },
+ workflow: { type: 'text', value: 'New Lead Assignment' },
+ status: {
+ type: 'select',
+ color: 'green',
+ value: 'Success',
+ },
+ startedAt: {
+ type: 'text',
+ value: 'Oct 24, 2023 10:00 am',
+ },
+ duration: { type: 'text', value: '2s' },
+ },
+ },
+ {
+ id: 'run-12346',
+ cells: {
+ runId: {
+ type: 'text',
+ value: 'run_12346',
+ shortLabel: 'R',
+ tone: 'amber',
+ },
+ workflow: { type: 'text', value: 'Nurture Sequence' },
+ status: { type: 'select', color: 'red', value: 'Failed' },
+ startedAt: {
+ type: 'text',
+ value: 'Oct 20, 2023 3:15 pm',
+ },
+ duration: { type: 'text', value: '5s' },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'workflow-versions',
+ label: 'Workflows versions',
+ icon: { kind: 'tabler', name: 'versions', tone: 'gray' },
+ page: createTablePage({
+ title: 'All Versions',
+ count: 2,
+ columns: [
+ {
+ id: 'version',
+ label: 'Version',
+ width: 120,
+ isFirstColumn: true,
+ },
+ { id: 'workflow', label: 'Workflow', width: 200 },
+ { id: 'publishedAt', label: 'Published At', width: 200 },
+ { id: 'publishedBy', label: 'Published By', width: 160 },
+ ],
+ rows: [
+ {
+ id: 'v2-lead',
+ cells: {
+ version: {
+ type: 'text',
+ value: 'v2',
+ shortLabel: 'V',
+ tone: 'amber',
+ },
+ workflow: { type: 'text', value: 'New Lead Assignment' },
+ publishedAt: {
+ type: 'text',
+ value: 'Oct 15, 2023 9:00 am',
+ },
+ publishedBy: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ shortLabel: 'I',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ },
+ },
+ {
+ id: 'v1-lead',
+ cells: {
+ version: {
+ type: 'text',
+ value: 'v1',
+ shortLabel: 'V',
+ tone: 'amber',
+ },
+ workflow: { type: 'text', value: 'New Lead Assignment' },
+ publishedAt: {
+ type: 'text',
+ value: 'Sep 10, 2023 1:00 pm',
+ },
+ publishedBy: {
+ type: 'person',
+ name: 'Ivan Zhao',
+ shortLabel: 'I',
+ tone: 'gray',
+ kind: 'person',
+ avatarUrl: PEOPLE_AVATAR_URLS.ivanZhao,
+ },
+ },
+ },
+ ],
+ }),
+ },
+ ],
+ },
+ {
+ id: 'book-demo',
+ label: 'Book a demo',
+ href: 'https://cal.com/forms/f7841033-0a20-4958-8c92-4e34ec128a81',
+ icon: {
+ kind: 'brand',
+ brand: 'twenty',
+ imageSrc: '/images/home/hero/twenty-demo-logo.webp',
+ overlay: 'link',
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/packages/twenty-website/src/sections/AppPreview/index.ts b/packages/twenty-website/src/sections/AppPreview/index.ts
index e214795783..b8280db8e0 100644
--- a/packages/twenty-website/src/sections/AppPreview/index.ts
+++ b/packages/twenty-website/src/sections/AppPreview/index.ts
@@ -1,10 +1,13 @@
export { AppPreview } from './AppPreview';
+export { APP_PREVIEW_DATA } from './app-preview.data';
export { Chip } from './Shared/components/Chip';
export { type ChipProps } from './Shared/utils/chip-props';
export { ChipVariant } from './Shared/utils/chip-variant';
export { VISUAL_TOKENS } from './Shared/utils/app-preview-tokens';
+export type { AppPreviewFrameMode } from './AppWindow/AppPreviewFrame';
export type {
AppPreviewConfig,
+ AppPreviewSidebarConfig,
DashboardData,
DashboardPageDefinition,
KanbanPageDefinition,
diff --git a/packages/twenty-website/src/sections/AppPreview/types/app-preview-data.ts b/packages/twenty-website/src/sections/AppPreview/types/app-preview-data.ts
index 7c6d9a8dbd..3f08270b48 100644
--- a/packages/twenty-website/src/sections/AppPreview/types/app-preview-data.ts
+++ b/packages/twenty-website/src/sections/AppPreview/types/app-preview-data.ts
@@ -1,14 +1,35 @@
export type CellText = {
type: 'text';
- targetLabel?: string;
+ targetPageItemId?: string;
value: string;
shortLabel?: string;
tone?: string;
};
export type CellNumber = { type: 'number'; value: string };
-export type CellLink = { type: 'link'; value: string };
+export type CellCurrency = { type: 'currency'; value: string };
+
+export type CellLink = {
+ type: 'link';
+ kind?: 'email' | 'phone' | 'social' | 'url';
+ label?: string;
+ value: string;
+};
export type CellBoolean = { type: 'boolean'; value: boolean };
-export type CellTag = { type: 'tag'; value: string };
+
+export type CellSelect = {
+ type: 'select';
+ color?:
+ | 'amber'
+ | 'blue'
+ | 'gray'
+ | 'green'
+ | 'orange'
+ | 'pink'
+ | 'purple'
+ | 'red'
+ | 'teal';
+ value: string;
+};
export type CellPerson = {
type: 'person';
@@ -33,9 +54,10 @@ export type CellRelation = {
export type CellValue =
| CellText
| CellNumber
+ | CellCurrency
| CellLink
| CellBoolean
- | CellTag
+ | CellSelect
| CellPerson
| CellEntity
| CellRelation;
@@ -53,24 +75,53 @@ export type RowDef = {
cells: Record;
};
-export type DashboardMetric = {
- id: string;
- title: string;
+export type DashboardTrend = {
+ direction: 'up' | 'down';
value: string;
};
-export type DashboardChartImage = {
- alt: string;
- height: number;
- src: string;
- width: number;
+export type DashboardKpi = {
+ id: string;
+ title: string;
+ value: string;
+ trend?: DashboardTrend;
+};
+
+export type DashboardBar = {
+ label: string;
+ value: number;
+};
+
+export type DashboardBarChart = {
+ title: string;
+ bars: DashboardBar[];
+};
+
+export type DashboardLineChart = {
+ title: string;
+ labels: string[];
+ values: number[];
+};
+
+export type DashboardDonutSlice = {
+ color: string;
+ label: string;
+ value: number;
+};
+
+export type DashboardDonutChart = {
+ title: string;
+ centerLabel: string;
+ centerValue: string;
+ slices: DashboardDonutSlice[];
};
export type DashboardData = {
- distributionChart: DashboardChartImage;
- metrics: DashboardMetric[];
- revenueChart: DashboardChartImage;
- visitsChart: DashboardChartImage;
+ kpis: DashboardKpi[];
+ barChart?: DashboardBarChart;
+ donutChart?: DashboardDonutChart;
+ lineChart?: DashboardLineChart;
+ generating?: boolean;
};
export type NavbarAction = {
@@ -92,6 +143,7 @@ export type PageHeader = {
export type TablePageDefinition = {
columns: ColumnDef[];
+ generating?: boolean;
header: PageHeader;
rows: RowDef[];
type: 'table';
@@ -136,6 +188,7 @@ export type WorkflowBranchLabelDef = {
export type WorkflowPageDefinition = {
branchLabels?: WorkflowBranchLabelDef[];
edges?: WorkflowEdgeDef[];
+ generating?: boolean;
header: PageHeader;
nodes?: WorkflowNodeDef[];
plusNode?: { x: number; y: number };
@@ -163,21 +216,30 @@ export type KanbanLane = {
};
export type KanbanPageDefinition = {
+ generating?: boolean;
header: PageHeader;
lanes: KanbanLane[];
type: 'kanban';
};
+export type RecordFieldValue =
+ | CellBoolean
+ | CellCurrency
+ | CellLink
+ | CellPerson
+ | CellSelect
+ | CellText;
+
export type RecordField = {
- avatarUrl?: string;
icon?: string;
label: string;
- value: string;
+ value: RecordFieldValue;
};
export type RecordRelation = {
avatarUrl?: string;
domain?: string;
+ highlighted?: boolean;
icon?: SidebarIcon;
name: string;
};
@@ -186,11 +248,93 @@ export type RecordNote = {
id: string;
title: string;
body: string;
+ highlighted?: boolean;
relation?: { avatarUrl?: string; name: string };
};
+export type TimelineFieldDiff = {
+ label: string;
+ value: RecordFieldValue;
+};
+
+export type TimelineEvent =
+ | {
+ kind: 'created';
+ id: string;
+ subject: string;
+ actor: string;
+ time: string;
+ }
+ | {
+ kind: 'updated';
+ id: string;
+ actor: string;
+ record: string;
+ time: string;
+ diffs: TimelineFieldDiff[];
+ }
+ | { kind: 'note'; id: string; actor: string; title: string; time: string }
+ | {
+ kind: 'calendar';
+ id: string;
+ actor: string;
+ title: string;
+ detail: string;
+ time: string;
+ };
+
+export type RecordParticipant = {
+ name: string;
+ avatarUrl?: string;
+ tone?: string;
+};
+
+export type RecordActivityTarget = RecordParticipant & { domain?: string };
+
+export type RecordTask = {
+ id: string;
+ title: string;
+ body: string;
+ due: string;
+ done?: boolean;
+ target: RecordActivityTarget;
+};
+
+export type RecordFile = {
+ id: string;
+ name: string;
+ category: 'pdf' | 'sheet' | 'doc' | 'other';
+ date: string;
+};
+
+export type RecordEmail = {
+ id: string;
+ participants: RecordParticipant[];
+ count: number;
+ subject: string;
+ body: string;
+ date: string;
+};
+
+export type RecordCalendarEvent = {
+ id: string;
+ start: string;
+ end: string;
+ title: string;
+ attending?: boolean;
+ participants: RecordParticipant[];
+};
+
+export type RecordCalendarDay = {
+ id: string;
+ weekday: string;
+ day: string;
+ events: RecordCalendarEvent[];
+};
+
export type RecordPageDefinition = {
type: 'record';
+ activeTabLabel?: string;
header: PageHeader;
record: {
logoDomain?: string;
@@ -205,6 +349,11 @@ export type RecordPageDefinition = {
}[];
};
notes: RecordNote[];
+ timeline?: TimelineEvent[];
+ tasks?: RecordTask[];
+ files?: RecordFile[];
+ emails?: RecordEmail[];
+ calendar?: RecordCalendarDay[];
};
export type PageDefinition =
@@ -233,34 +382,43 @@ export type SidebarIcon =
shape?: 'circle' | 'square';
};
-export type SidebarItemDef = {
+type SidebarBaseItemDef = {
+ hidden?: boolean;
id: string;
label: string;
- href?: string;
icon: SidebarIcon;
- page?: PageDefinition;
meta?: string;
- active?: boolean;
- showChevron?: boolean;
- children?: SidebarItemDef[];
};
+export type SidebarPageItemDef = SidebarBaseItemDef & {
+ page: PageDefinition;
+ href?: never;
+};
+
+export type SidebarLinkItemDef = SidebarBaseItemDef & {
+ href: string;
+ page?: never;
+};
+
+export type SidebarItemDef = SidebarLinkItemDef | SidebarPageItemDef;
+
export type SidebarFolderDef = {
id: string;
label: string;
icon: SidebarIcon;
- defaultOpen?: boolean;
- showChevron?: boolean;
- children?: SidebarItemDef[];
- items: SidebarItemDef[];
+ items: SidebarPageItemDef[];
};
export type SidebarEntry = SidebarItemDef | SidebarFolderDef;
-export type AppPreviewConfig = {
- workspace: { icon: string; name: string };
- favoritesNav?: SidebarItemDef[];
- workspaceNav: SidebarEntry[];
- tableWidth?: number;
- actions?: string[];
+export type AppPreviewSidebarConfig = {
+ favorites: SidebarItemDef[];
+ initialActiveItemId: string;
+ initialOpenFolderIds: string[];
+ workspace: SidebarEntry[];
+};
+
+export type AppPreviewConfig = {
+ defaultViewbarActions: string[];
+ sidebar: AppPreviewSidebarConfig;
};
diff --git a/packages/twenty-website/src/sections/AppPreview/types/index.ts b/packages/twenty-website/src/sections/AppPreview/types/index.ts
index e88afd8762..76f4206c01 100644
--- a/packages/twenty-website/src/sections/AppPreview/types/index.ts
+++ b/packages/twenty-website/src/sections/AppPreview/types/index.ts
@@ -1,7 +1,12 @@
export type {
- DashboardChartImage,
DashboardData,
- DashboardMetric,
+ DashboardTrend,
+ DashboardKpi,
+ DashboardBar,
+ DashboardBarChart,
+ DashboardLineChart,
+ DashboardDonutSlice,
+ DashboardDonutChart,
DashboardPageDefinition,
KanbanCard,
KanbanLane,
@@ -9,12 +14,14 @@ export type {
RecordField,
RecordNote,
RecordPageDefinition,
+ RecordFieldValue,
RecordRelation,
WorkflowBranchLabelDef,
WorkflowEdgeDef,
WorkflowNodeDef,
WorkflowPageDefinition,
CellBoolean,
+ CellCurrency,
CellEntity,
CellLink,
CellNumber,
@@ -23,16 +30,27 @@ export type {
NavbarAction,
PageHeader,
CellRelation,
- CellTag,
+ CellSelect,
CellText,
CellValue,
ColumnDef,
RowDef,
PageType,
SidebarEntry,
+ AppPreviewSidebarConfig,
SidebarFolderDef,
SidebarIcon,
+ SidebarLinkItemDef,
+ SidebarPageItemDef,
SidebarItemDef,
TablePageDefinition,
+ TimelineEvent,
+ RecordParticipant,
+ RecordActivityTarget,
+ RecordTask,
+ RecordFile,
+ RecordEmail,
+ RecordCalendarEvent,
+ RecordCalendarDay,
AppPreviewConfig,
} from './app-preview-data';
diff --git a/packages/twenty-website/src/sections/CaseStudy/components/Hero.tsx b/packages/twenty-website/src/sections/CaseStudy/components/Hero.tsx
index 11cdeb2793..9a0326c302 100644
--- a/packages/twenty-website/src/sections/CaseStudy/components/Hero.tsx
+++ b/packages/twenty-website/src/sections/CaseStudy/components/Hero.tsx
@@ -1,6 +1,6 @@
import type { CaseStudyData } from '@/lib/customers';
import { Container } from '@/design-system/components';
-import { CLIENT_ICONS } from '@/icons';
+import { CLIENT_ICONS, type ClientIconKey } from '@/icons';
import { getServerI18n } from '@/lib/i18n/server';
import { LocalizedLink } from '@/lib/i18n';
import { CustomerCasesCover } from '@/sections/CaseStudyCatalog/visuals/CustomerCasesCover';
@@ -9,7 +9,7 @@ import { msg } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { IconArrowLeft, IconClock } from '@tabler/icons-react';
import Image from 'next/image';
-const CATALOG_LOGO_WIDTHS: Record = {
+const CATALOG_LOGO_WIDTHS: Record = {
'nine-dots': 72,
'alternative-partners': 220,
netzero: 180,
@@ -242,8 +242,7 @@ export function Hero({ hero, dashColor, hoverDashColor }: HeroProps) {
.join('')
.toUpperCase();
- const logoWidth =
- (CATALOG_LOGO_WIDTHS[hero.clientIcon] ?? 140) * HERO_LOGO_SCALE;
+ const logoWidth = CATALOG_LOGO_WIDTHS[hero.clientIcon] * HERO_LOGO_SCALE;
return (
diff --git a/packages/twenty-website/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx b/packages/twenty-website/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx
index 4f2fa85f2e..4638e0b5b2 100644
--- a/packages/twenty-website/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx
+++ b/packages/twenty-website/src/sections/CaseStudyCatalog/components/CardThumbnail.tsx
@@ -86,7 +86,7 @@ export function CardThumbnail({
variant,
}: CardThumbnailProps) {
const ClientIcon = CLIENT_ICONS[clientIcon];
- const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon] ?? 140;
+ const baseLogoWidth = CATALOG_LOGO_WIDTHS[clientIcon];
const logoWidth =
variant === 'large' ? baseLogoWidth * LARGE_LOGO_SCALE : baseLogoWidth;
diff --git a/packages/twenty-website/src/sections/Helped/components/HelpedCard.tsx b/packages/twenty-website/src/sections/Helped/components/HelpedCard.tsx
index 8093d0ac96..b44444f1c9 100644
--- a/packages/twenty-website/src/sections/Helped/components/HelpedCard.tsx
+++ b/packages/twenty-website/src/sections/Helped/components/HelpedCard.tsx
@@ -134,9 +134,7 @@ export function HelpedCard({ card }: CardProps) {
strokeColor={theme.colors.secondary.border[40]}
/>
- {IconComponent ? (
-
- ) : null}
+
diff --git a/packages/twenty-website/src/sections/Helped/types/heading-card-type.ts b/packages/twenty-website/src/sections/Helped/types/heading-card-type.ts
index 26cd0081d8..1cabcd60ce 100644
--- a/packages/twenty-website/src/sections/Helped/types/heading-card-type.ts
+++ b/packages/twenty-website/src/sections/Helped/types/heading-card-type.ts
@@ -1,9 +1,10 @@
+import type { ClientIconKey } from '@/icons';
import type { MessageDescriptor } from '@lingui/core';
import type { HelpedVisualId } from './helped-visual-id';
export type HeadingCardType = {
- icon: string;
+ icon: ClientIconKey;
illustration: HelpedVisualId;
heading: MessageDescriptor;
body: MessageDescriptor;
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
index cb79c82b83..043a629a8c 100644
--- a/packages/twenty-website/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/HeroVisualScroll.tsx
@@ -1,16 +1,24 @@
'use client';
-import { type ReactNode, useId, useRef, useState } from 'react';
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { Container, LinkButton } from '@/design-system/components';
+import { useMediaQuery } from '@/lib/motion';
import type { AppPreviewConfig } from '@/sections/AppPreview';
+import { TabButton } from '@/sections/Tabs/components/TabButton';
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 { useProductHeroMenuSync } from './product-hero-menu-sync';
+import { ProductBackgroundHalftone } from './ProductBackgroundHalftone';
import { ProductVisual } from './ProductVisual';
import { useHeroScrollProgress } from './use-hero-scroll-progress';
@@ -21,70 +29,77 @@ export type HeroScrollProps = {
ctaLabel: string;
introBody: string;
introHeading: ReactNode;
+ introSecondaryCta?: ReactNode;
tabs: TabType[];
visual: AppPreviewConfig;
};
+type StackTargetMetric = {
+ offset: number;
+ width: number;
+};
+
const NAV_HEIGHT = 64;
+const MD_UP_QUERY = `(min-width: ${theme.breakpoints.md}px)`;
+const AI_PANEL_ONLY_MAX_WIDTH = 599.98;
+
+const PRODUCT_HERO_BACKGROUND_IMAGE =
+ '/illustrations/generated/home-background-wheat.webp';
+
+const INTRO_DASH_COLOR = '#4A38F5';
+const AI_DASH_COLOR = '#ffffff';
+
+const PatternOverlay = styled.div`
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ z-index: 0;
+`;
+
const ScrollTrack = styled.section`
+ height: 200vh;
+ margin-top: -${NAV_HEIGHT}px;
position: relative;
width: 100%;
+ @media (max-width: ${theme.breakpoints.md - 0.02}px) {
+ display: none;
+ }
+`;
+
+const MobileRoot = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
- height: 200vh;
+ display: none;
}
`;
const StickyFrame = styled.div`
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ position: sticky;
+ top: 0;
+ width: 100%;
+
+ background-color: #ffffff;
+`;
+
+const FullLayer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
+ inset: 0;
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);
- }
+ padding-top: 94px;
+ position: absolute;
+ row-gap: ${theme.spacing(6)};
@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;
+ padding-top: 112px;
}
`;
-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);
@@ -93,7 +108,7 @@ const StyledContainer = styled(Container)`
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
position: relative;
- row-gap: ${theme.spacing(6)};
+ row-gap: ${theme.spacing(8)};
text-align: center;
width: 100%;
z-index: 1;
@@ -107,53 +122,31 @@ const StyledContainer = styled(Container)`
const HeadingSlot = styled.div`
max-width: 360px;
min-height: 96px;
+ position: relative;
+ width: 100%;
@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`
+const BodyText = styled.div`
font-size: ${theme.font.size(4)};
line-height: 1.55;
margin: 0;
max-width: 360px;
- transition: color 0.6s ease;
+ position: relative;
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;
}
@@ -168,7 +161,7 @@ const HeadingGroup = styled.div`
`;
const ActionSlot = styled.div`
- min-height: 48px;
+ min-height: 40px;
position: relative;
width: 100%;
`;
@@ -179,37 +172,95 @@ const CtaLayer = styled.div`
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;
+ &[data-active='true'] {
+ position: relative;
}
`;
-const TabsLayer = styled.div`
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.4s ease 0.2s;
+const MeasureTabButtons = styled(TabButtons)`
width: 100%;
- &[data-visible='true'] {
- opacity: 1;
- pointer-events: auto;
+ @media (min-width: ${theme.breakpoints.lg}px) {
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
}
+`;
- @media (min-width: ${theme.breakpoints.md}px) {
- inset: 0;
- position: absolute;
+const StackedTabDeck = styled.div`
+ display: none;
+ inset: 0;
+ position: absolute;
+
+ @media (min-width: ${theme.breakpoints.lg}px) {
+ display: block;
+ }
+`;
+
+const StackedTabCard = styled.div`
+ left: 50%;
+ max-width: min(500px, calc(100vw - 160px));
+ opacity: var(--hero-stack-opacity, 0);
+ position: absolute;
+ top: 0;
+ transform-origin: center top;
+ transition: none;
+ width: var(--hero-stack-width, auto);
+ will-change: opacity, transform;
+
+ & > button {
+ background-color: ${theme.colors.secondary.background[100]};
+ background-image: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.1) 0%,
+ rgba(255, 255, 255, 0.1) 100%
+ );
+ border: 1px solid ${theme.colors.secondary.border[10]};
+ box-sizing: border-box;
+ max-width: none;
+ width: 100%;
}
`;
const VisualWrapper = styled.div`
+ display: flex;
flex: 1;
+ flex-direction: column;
min-height: 0;
overflow: hidden;
+ position: relative;
+ width: 100%;
+ z-index: 1;
+`;
+
+const MobileSection = styled.section<{ $secondary?: boolean }>`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding-top: ${({ $secondary }) =>
+ $secondary ? theme.spacing(12) : theme.spacing(7.5)};
+ position: relative;
+ row-gap: ${theme.spacing(6)};
+ width: 100%;
+`;
+
+const MobileVisualWrapper = styled.div`
+ isolation: isolate;
+ overflow: hidden;
+ padding-bottom: ${theme.spacing(16)};
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+ position: relative;
+ width: 100%;
+`;
+
+// Non-clipping layer that hosts the collaboration cursors so they can sit above
+// the visual (like the desktop FullLayer); the inner wrapper still clips the bleed.
+const MobileCursorLayer = styled.div`
+ position: relative;
width: 100%;
`;
@@ -220,66 +271,410 @@ export function HeroVisualScroll({
ctaLabel,
introBody,
introHeading,
+ introSecondaryCta,
tabs,
visual,
}: HeroScrollProps) {
const trackRef = useRef(null);
- const phase = useHeroScrollProgress(trackRef);
+ const rowButtonsRef = useRef(null);
+ const { morphProgress, navProgress, menuBackground, menuElevated } =
+ useHeroScrollProgress(trackRef);
+ const menuSync = useProductHeroMenuSync();
+ const isDesktop = useMediaQuery(MD_UP_QUERY, { serverFallback: true });
+ // Below 600px the board + AI panel can't coexist, so the AI section becomes a
+ // panel-only "Ask AI" view.
+ const isPhone = useMediaQuery(`(max-width: ${AI_PANEL_ONLY_MAX_WIDTH}px)`, {
+ serverFallback: false,
+ });
const [activeTab, setActiveTab] = useState(0);
- const idPrefix = useId();
+ const [introLayerEl, setIntroLayerEl] = useState(null);
+ const [mobileIntroLayerEl, setMobileIntroLayerEl] =
+ useState(null);
+ const [stackTargetMetrics, setStackTargetMetrics] = useState<
+ StackTargetMetric[]
+ >([]);
+ const clamp = (value: number) => Math.max(0, Math.min(1, value));
+ const stackAppearProgress = clamp((morphProgress - 0.4) / 0.16);
+ const stackAlignProgress = clamp((morphProgress - 0.62) / 0.04);
+ const stackSpreadProgress = clamp((morphProgress - 0.66) / 0.27);
+ const stackSpreadEasedProgress = 1 - Math.pow(1 - stackSpreadProgress, 2.6);
+ const selectorRevealProgress = clamp((morphProgress - 0.94) / 0.06);
+ const selectorRevealReady = selectorRevealProgress > 0.96;
+ const stackCards = tabs;
- const activeScene = phase === 0 ? 0 : activeTab + 1;
+ const stackStyle = {
+ '--hero-stack-opacity': String(stackAppearProgress),
+ '--hero-stack-shift-y': `${(1 - stackAppearProgress) * 16}px`,
+ } as CSSProperties;
- return (
-
-
-
-
-
+ const stackBaseOffsets = [0, 4, 8, 12];
+ const stackBaseScales = [1, 0.99, 0.98, 0.97];
+ const stackSpreadMetrics =
+ stackTargetMetrics.length === tabs.length ? stackTargetMetrics : null;
+ const stackWidth = stackSpreadMetrics?.[0]?.width ?? null;
+ const aiPanelProgress = clamp((morphProgress - 0.45) / 0.25);
+ const aiPlaybackEnabled = morphProgress >= 0.7;
+
+ useEffect(() => {
+ if (!menuSync) {
+ return;
+ }
+
+ if (!isDesktop) {
+ menuSync.setMenuState({
+ backgroundColor: 'rgb(255, 255, 255)',
+ disableElevation: false,
+ scheme: 'primary',
+ });
+ return;
+ }
+
+ menuSync.setMenuState({
+ backgroundColor: menuBackground,
+ disableElevation: !menuElevated,
+ scheme: navProgress >= 0.5 ? 'secondary' : 'primary',
+ });
+ }, [menuSync, isDesktop, navProgress, menuBackground, menuElevated]);
+
+ const heroAtStart = morphProgress <= 0;
+
+ useEffect(() => {
+ if (heroAtStart) {
+ setActiveTab(0);
+ }
+ }, [heroAtStart]);
+
+ useEffect(() => {
+ const updateStackTargets = () => {
+ const rowContainer = rowButtonsRef.current;
+
+ if (!(rowContainer instanceof HTMLElement)) {
+ return;
+ }
+
+ const buttons = Array.from(rowContainer.querySelectorAll('button'));
+
+ if (buttons.length !== tabs.length) {
+ return;
+ }
+
+ const containerRect = rowContainer.getBoundingClientRect();
+ const containerCenter = containerRect.left + containerRect.width / 2;
+
+ setStackTargetMetrics(
+ buttons.map((button) => {
+ const rect = button.getBoundingClientRect();
+
+ return {
+ offset: rect.left + rect.width / 2 - containerCenter,
+ width: rect.width,
+ };
+ }),
+ );
+ };
+
+ updateStackTargets();
+
+ window.addEventListener('resize', updateStackTargets);
+
+ return () => {
+ window.removeEventListener('resize', updateStackTargets);
+ };
+ }, [tabs]);
+
+ const mobileLayout = (
+
+
-
-
- {introHeading}
-
- {aiHeading}
+
+ {introHeading}
-
-
- {phase === 0 ? introBody : aiBody}
+
+ {introBody}
-
+
+ {introSecondaryCta}
-
-
-
-
-
-
-
-
+
+
+
+ {!isDesktop ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ {aiHeading}
+
+
+ {aiBody}
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isDesktop ? (
+
+ ) : null}
+
+
+
+
+
+ );
+
+ return (
+ <>
+ {mobileLayout}
+
+
+ {/* BASE LAYER: INTRO */}
+
+
+
+
+ {introHeading}
+
+ {aiHeading}
+
+
+
+ {introBody}
+
+ {aiBody}
+
+
+
+
+
+
+
+ {introSecondaryCta}
+
+
+
+
+
+
+
+
+
+ {isDesktop ? (
+
+ ) : null}
+
+
+
+
+
+ {/* TOP LAYER: AI (Wipes up from bottom) */}
+ 0.5 ? 'auto' : 'none',
+ // Prevent text from rendering sub-pixel anti-aliasing differently than the base layer
+ transform: 'translateZ(0)',
+ }}
+ >
+
+
+
+
+ {introHeading}
+
+ {aiHeading}
+
+
+
+ {introBody}
+
+ {aiBody}
+
+
+
+
+
+
+
+
+
+
+ {stackCards.map((tab, index) =>
+ (() => {
+ const targetMetric = stackSpreadMetrics?.[index];
+ const width =
+ stackWidth != null && targetMetric != null
+ ? stackWidth +
+ (targetMetric.width - stackWidth) *
+ stackSpreadEasedProgress
+ : (stackWidth ?? targetMetric?.width);
+ const offset = targetMetric?.offset ?? 0;
+
+ return (
+
+ setActiveTab(index)}
+ tab={tab}
+ />
+
+ );
+ })(),
+ )}
+
+
+
+
+
+
+
+ {isDesktop ? (
+
+ ) : null}
+
+
+
+
+
+
+ >
);
}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductBackgroundHalftone.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductBackgroundHalftone.tsx
new file mode 100644
index 0000000000..e94757335b
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductBackgroundHalftone.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { styled } from '@linaria/react';
+import { useRef } from 'react';
+import { useProductBackgroundHalftone } from './use-product-background-halftone';
+
+const StyledMount = styled.div<{ $isReady: boolean }>`
+ height: 100%;
+ inset: 0;
+ opacity: ${({ $isReady }) => ($isReady ? 1 : 0)};
+ position: absolute;
+ transition: opacity 600ms ease;
+ width: 100%;
+`;
+
+type ProductBackgroundHalftoneProps = {
+ imageUrl: string;
+ dashColor?: string;
+ hoverColor?: string;
+};
+
+export function ProductBackgroundHalftone({
+ imageUrl,
+ dashColor,
+ hoverColor,
+}: ProductBackgroundHalftoneProps) {
+ const mountReference = useRef(null);
+ const isReady = useProductBackgroundHalftone({
+ imageUrl,
+ dashColor,
+ hoverColor,
+ mountRef: mountReference,
+ });
+
+ return ;
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductHeroCursor.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductHeroCursor.tsx
new file mode 100644
index 0000000000..0881ce02e7
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductHeroCursor.tsx
@@ -0,0 +1,247 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { styled } from '@linaria/react';
+
+import { MarkerCursor } from '@/sections/ThreeCards/components/FeatureCard/components/MarkerCursor';
+import { theme } from '@/theme';
+import type { CursorTarget } from './use-product-hero-cursor-autoplay';
+
+type Coordinate = { left: number; top: number };
+
+export type HeroCursorConfig = {
+ color: string;
+ home: Coordinate;
+ // Resting position used on the phone bleed layout, where the window runs off
+ // the right edge; defaults to home when unset. Tune per cursor.
+ mobileHome?: Coordinate;
+ name: string;
+};
+
+export const HERO_CURSORS: HeroCursorConfig[] = [
+ {
+ name: 'Alice',
+ color: '#ffb08d',
+ home: { left: 13, top: 34 },
+ mobileHome: { left: 13, top: -6 },
+ },
+ { name: 'Ben', color: '#8db4ff', home: { left: 36, top: 90 } },
+ { name: 'Cara', color: '#9ee7c5', home: { left: 90, top: 51 } },
+];
+
+const GLIDE_BASE_MS = 500;
+const GLIDE_MS_PER_PX = 0.55;
+const GLIDE_MIN_MS = 620;
+const GLIDE_MAX_MS = 1000;
+const GLIDE_SKIP_PX = 6;
+
+const ROW_X_OFFSET_PX = 80;
+const ROW_Y_OFFSET_PX = -5;
+const RAIL_X_OFFSET_PX = -4;
+const RAIL_Y_OFFSET_PX = -4;
+const TAB_X_OFFSET_PX = 2;
+const TAB_Y_OFFSET_PX = -6;
+
+function pixelDistance(from: Coordinate, to: Coordinate, rect: DOMRect) {
+ const dx = ((to.left - from.left) / 100) * rect.width;
+ const dy = ((to.top - from.top) / 100) * rect.height;
+
+ return Math.hypot(dx, dy);
+}
+
+function glideForDistance(distance: number) {
+ return Math.max(
+ GLIDE_MIN_MS,
+ Math.min(GLIDE_MAX_MS, GLIDE_BASE_MS + distance * GLIDE_MS_PER_PX),
+ );
+}
+
+const Overlay = styled.div`
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ z-index: 3;
+`;
+
+const Marker = styled.div<{
+ $clicking: boolean;
+ $glideMs: number;
+ $hidden: boolean;
+ $left: number;
+ $top: number;
+}>`
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ left: ${({ $left }) => `${$left}%`};
+ opacity: ${({ $hidden }) => ($hidden ? 0 : 1)};
+ position: absolute;
+ top: ${({ $top }) => `${$top}%`};
+ transform: ${({ $clicking }) => ($clicking ? 'scale(0.86)' : 'scale(1)')};
+ transform-origin: top left;
+ transition:
+ left ${({ $glideMs }) => `${$glideMs}ms`} cubic-bezier(0.22, 1, 0.36, 1),
+ top ${({ $glideMs }) => `${$glideMs}ms`} cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 180ms ease,
+ transform 150ms ease;
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: opacity 180ms ease;
+ }
+`;
+
+const Label = styled.span<{ $color: string }>`
+ background: ${({ $color }) => $color};
+ border-radius: 4px;
+ color: #1f1f1f;
+ font-family: ${theme.font.family.mono};
+ font-size: 10px;
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.02em;
+ line-height: 1;
+ padding: 4px 8px;
+ text-transform: uppercase;
+ width: fit-content;
+`;
+
+const HOME_TARGET: CursorTarget = { kind: 'home' };
+
+type ProductHeroCursorProps = {
+ clicking: boolean;
+ color: string;
+ glideMs?: number;
+ hidden: boolean;
+ home: Coordinate;
+ name: string;
+ target?: CursorTarget;
+};
+
+export function ProductHeroCursor({
+ clicking,
+ color,
+ glideMs: glideMsOverride,
+ hidden,
+ home,
+ name,
+ target = HOME_TARGET,
+}: ProductHeroCursorProps) {
+ const overlayRef = useRef(null);
+ const [coordinate, setCoordinate] = useState(home);
+ const [glideMs, setGlideMs] = useState(0);
+ const coordinateRef = useRef(home);
+
+ const moveTo = useCallback((next: Coordinate, explicitMs?: number) => {
+ const overlayRect = overlayRef.current?.getBoundingClientRect();
+
+ if (!overlayRect) {
+ coordinateRef.current = next;
+ setCoordinate(next);
+ return;
+ }
+
+ const distance = pixelDistance(coordinateRef.current, next, overlayRect);
+
+ if (distance < GLIDE_SKIP_PX) {
+ return;
+ }
+
+ coordinateRef.current = next;
+ setGlideMs(explicitMs ?? glideForDistance(distance));
+ setCoordinate(next);
+ }, []);
+
+ useEffect(() => {
+ if (target.kind === 'home') {
+ moveTo(home);
+ return undefined;
+ }
+
+ const selector =
+ target.kind === 'row'
+ ? `[data-row-id="${target.id}"]`
+ : target.kind === 'rail'
+ ? `[data-rail-item-id="${target.id}"]`
+ : `[data-record-tab="${target.id}"]`;
+
+ const measure = () => {
+ const overlay = overlayRef.current;
+ const scene = overlay?.parentElement;
+
+ if (!overlay || !scene) {
+ return;
+ }
+
+ const element = scene.querySelector(selector);
+
+ if (!(element instanceof HTMLElement)) {
+ return;
+ }
+
+ const overlayRect = overlay.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ if (elementRect.height === 0 || overlayRect.width === 0) {
+ return;
+ }
+
+ const xOffset =
+ target.kind === 'row'
+ ? ROW_X_OFFSET_PX
+ : target.kind === 'rail'
+ ? RAIL_X_OFFSET_PX
+ : TAB_X_OFFSET_PX;
+ const yOffset =
+ target.kind === 'row'
+ ? ROW_Y_OFFSET_PX
+ : target.kind === 'rail'
+ ? RAIL_Y_OFFSET_PX
+ : TAB_Y_OFFSET_PX;
+ const x =
+ target.kind === 'row'
+ ? elementRect.left + xOffset - overlayRect.left
+ : elementRect.left +
+ elementRect.width / 2 +
+ xOffset -
+ overlayRect.left;
+ const y =
+ elementRect.top + elementRect.height / 2 + yOffset - overlayRect.top;
+
+ moveTo(
+ {
+ left: (x / overlayRect.width) * 100,
+ top: (y / overlayRect.height) * 100,
+ },
+ glideMsOverride,
+ );
+ };
+
+ measure();
+ window.addEventListener('resize', measure);
+
+ const settleTimers = [120, 360, 720].map((delay) =>
+ setTimeout(measure, delay),
+ );
+
+ return () => {
+ window.removeEventListener('resize', measure);
+ settleTimers.forEach(clearTimeout);
+ };
+ }, [target, home, moveTo, glideMsOverride]);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisual.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
index 39bce96cce..c851d15c0d 100644
--- a/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisual.tsx
@@ -1,24 +1,39 @@
'use client';
+import { Fragment, useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
+
import { styled } from '@linaria/react';
+import {
+ IconArrowUp,
+ IconChevronDown,
+ IconEdit,
+ IconPaperclip,
+ IconX,
+} from '@tabler/icons-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 { AppPreviewFrame } from '@/sections/AppPreview/AppWindow/AppPreviewFrame';
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 { WindowOrderProvider } from '@/sections/AppPreview/WindowOrder/WindowOrderProvider';
+import { AppPreviewLayout } from '@/sections/AppPreview/Shell/AppPreviewLayout';
import { theme } from '@/theme';
-import { PROMPT_OPTIONS } from './product-visual.data';
+import { HERO_CURSORS, ProductHeroCursor } from './ProductHeroCursor';
+import { ProductVisualAiSteps } from './ProductVisualAiSteps';
+import { ANTHROPIC_RECORD_PAGE } from './product-visual.data';
+import { sliceVisibleParagraphs } from './streamed-markdown';
+import { useProductHeroCursorAutoplay } from './use-product-hero-cursor-autoplay';
import { useProductVisualAutoplay } from './use-product-visual-autoplay';
-const StyledRoot = styled.div`
+const MAX_VISIBLE_RESPONSE_CHIPS = 3;
+
+const StyledRoot = styled.div<{ $fill: boolean }>`
+ display: ${({ $fill }) => ($fill ? 'flex' : 'block')};
+ flex: ${({ $fill }) => ($fill ? '1' : 'none')};
+ flex-direction: column;
isolation: isolate;
margin-top: ${theme.spacing(5)};
+ min-height: 0;
position: relative;
text-align: left;
width: 100%;
@@ -28,76 +43,47 @@ const StyledRoot = styled.div`
}
`;
-const ShellScene = styled.div`
- aspect-ratio: 1280 / 832;
+const WINDOW_MAX_WIDTH = 1040;
+const WINDOW_HEIGHT = 676;
+// Phone (<600px) AI experience: the panel-only window is locked to a chat width.
+const PANEL_ONLY_WIDTH = 320;
+
+// bleed: fixed-width window that runs off the right edge when the viewport is
+// narrower than it. compact: same fixed height (no aspect scaling) but the width
+// fits the viewport (capped), so the board flexes while the sidebar + AI panel
+// stay legible — used by the morph so the AI panel never bleeds off-screen.
+// panelOnly: the compact window is capped to a fixed chat width.
+const ShellScene = styled.div<{
+ $bleed: boolean;
+ $compact: boolean;
+ $panelOnly: boolean;
+}>`
+ flex: 0 0 auto;
+ height: ${WINDOW_HEIGHT}px;
margin: 0 auto;
- max-height: 740px;
+ max-width: ${({ $compact, $panelOnly }) =>
+ $panelOnly
+ ? `${PANEL_ONLY_WIDTH}px`
+ : $compact
+ ? `${WINDOW_MAX_WIDTH}px`
+ : 'none'};
+ min-height: 0;
position: relative;
- width: 100%;
+ width: ${({ $bleed }) => ($bleed ? `${WINDOW_MAX_WIDTH}px` : '100%')};
`;
-const AppLayout = styled.div`
- display: flex;
- flex: 1 1 auto;
- height: 100%;
- min-height: 0;
- min-width: 0;
- overflow: hidden;
- position: relative;
- width: 100%;
- z-index: 1;
-`;
-
-const RightColumn = styled.div`
- display: flex;
- flex: 1 1 0;
- flex-direction: column;
- gap: 12px;
- min-height: 0;
- min-width: 0;
- padding: 12px 12px 12px 0;
-`;
-
-const ContentRow = styled.div`
- display: flex;
- flex: 1 1 auto;
- gap: 8px;
- min-height: 0;
-`;
-
-const IndexSurface = styled.div`
- background: ${COLORS.background};
- border: 1px solid ${COLORS.border};
- border-radius: 8px;
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- min-height: 0;
- min-width: 0;
- overflow: hidden;
-
- [aria-label*='workflow'] > div > div {
- left: 0;
- transform: scale(0.65) translateX(-20%);
- transform-origin: top left;
- }
-`;
-
-const AiPanel = styled.aside`
+const AiPanel = styled.aside<{ $panelOnly: boolean }>`
background: ${VISUAL_TOKENS.background.primary};
- border: 1px solid ${VISUAL_TOKENS.border.color.medium};
- border-radius: 8px;
+ border: ${({ $panelOnly }) =>
+ $panelOnly ? 'none' : `1px solid ${VISUAL_TOKENS.border.color.medium}`};
+ border-radius: ${({ $panelOnly }) => ($panelOnly ? '0' : '8px')};
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
min-height: 0;
overflow: hidden;
- width: 280px;
-
- @media (max-width: ${theme.breakpoints.md}px) {
- display: none;
- }
+ width: ${({ $panelOnly }) => ($panelOnly ? '100%' : '280px')};
`;
const AiPanelHeader = styled.div`
@@ -114,11 +100,11 @@ const AiPanelHeader = styled.div`
const AiHeaderBtn = styled.span`
align-items: center;
border-radius: 4px;
- color: ${VISUAL_TOKENS.font.color.secondary};
+ color: ${VISUAL_TOKENS.font.color.tertiary};
display: flex;
- height: 28px;
+ height: 20px;
justify-content: center;
- width: 28px;
+ width: 20px;
`;
const AiPanelTitle = styled.span`
@@ -126,7 +112,7 @@ const AiPanelTitle = styled.span`
flex: 1;
font-size: 13px;
font-weight: 600;
- text-align: center;
+ text-align: left;
`;
const AiMessages = styled.div`
@@ -139,12 +125,13 @@ const AiMessages = styled.div`
`;
const UserMsg = styled.div`
- background: ${VISUAL_TOKENS.background.transparent.medium};
- border-radius: ${VISUAL_TOKENS.border.radius.sm};
+ align-self: flex-end;
+ background: #f1f1f1;
+ border-radius: 4px;
color: ${VISUAL_TOKENS.font.color.secondary};
font-size: 13px;
font-weight: 500;
- line-height: 1.4em;
+ line-height: 1.5;
padding: 4px 8px;
width: fit-content;
`;
@@ -153,65 +140,96 @@ const AiMsg = styled.div`
color: ${VISUAL_TOKENS.font.color.primary};
font-size: 13px;
font-weight: 400;
- line-height: 1.4em;
+ line-height: 1.5;
width: 100%;
`;
+const AiMsgStrong = styled.strong`
+ color: ${VISUAL_TOKENS.font.color.primary};
+ font-weight: 500;
+`;
+
+const AiMsgParagraph = styled.div`
+ line-height: inherit;
+ margin-block: 8px;
+
+ &:first-child {
+ margin-block-start: 0;
+ }
+
+ &:last-child {
+ margin-block-end: 0;
+ }
+`;
+
+const EntityChips = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 12px;
+`;
+
+const EntityChip = styled.div`
+ align-items: center;
+ background: rgba(0, 0, 0, 0.04);
+ border-radius: 4px;
+ display: flex;
+ gap: 4px;
+ max-width: 100%;
+ padding: 3px 6px;
+ width: fit-content;
+`;
+
+const EntityChipIcon = styled.img`
+ border-radius: 2px;
+ height: 14px;
+ object-fit: cover;
+ width: 14px;
+`;
+
+const EntityChipName = styled.span`
+ color: ${VISUAL_TOKENS.font.color.primary};
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 1.4;
+ white-space: nowrap;
+`;
+
+const EntityOverflowChip = styled.div`
+ align-items: center;
+ background: rgba(0, 0, 0, 0.04);
+ border-radius: 4px;
+ color: ${VISUAL_TOKENS.font.color.secondary};
+ display: flex;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ padding: 3px 6px;
+`;
+
const ThinkingText = styled.span`
color: ${VISUAL_TOKENS.font.color.tertiary};
- font-size: 13px;
-`;
-
-const PromptOption = styled.button`
- align-items: center;
- background: none;
- border: none;
- border-radius: 4px;
- color: ${VISUAL_TOKENS.font.color.primary};
- cursor: pointer;
- display: flex;
- font-size: 13px;
- gap: 8px;
- line-height: 1.4;
- padding: 6px 4px;
- text-align: left;
- width: 100%;
-
- &:hover {
- background: ${VISUAL_TOKENS.background.transparent.light};
- }
-`;
-
-const PromptOptionIcon = styled.span`
- align-items: center;
- color: ${VISUAL_TOKENS.font.color.secondary};
- display: flex;
- flex-shrink: 0;
-`;
-
-const PromptOptions = styled.div`
- display: flex;
- flex-direction: column;
- padding: 0 12px;
+ font-size: 12px;
+ font-weight: 500;
`;
const AiInputArea = styled.div`
- align-items: flex-end;
display: flex;
flex-direction: column;
flex-shrink: 0;
- gap: 8px;
padding: 12px;
`;
const AiInputBox = styled.div`
- background-color: ${VISUAL_TOKENS.background.transparent.lighter};
+ background-color: rgba(0, 0, 0, 0.02);
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
- border-radius: 8px;
+ border-radius: 4px;
display: flex;
flex-direction: column;
- min-height: 100px;
- padding: 12px;
+ height: 80px;
+ justify-content: space-between;
+ min-height: 32px;
+ padding: 8px;
width: 100%;
`;
@@ -219,13 +237,13 @@ const AiInputPlaceholder = styled.span`
color: ${VISUAL_TOKENS.font.color.light};
font-size: 13px;
font-weight: 400;
+ padding: 4px 0;
`;
const AiInputBtnRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
- margin-top: auto;
`;
const AiInputLeftBtns = styled.div`
@@ -244,12 +262,18 @@ const AiInputRightBtns = styled.div`
const ModelChip = styled.span`
align-items: center;
border: 1px solid ${VISUAL_TOKENS.border.color.medium};
- border-radius: 6px;
- color: ${VISUAL_TOKENS.font.color.secondary};
+ border-radius: 4px;
+ color: ${VISUAL_TOKENS.font.color.primary};
display: flex;
- font-size: 11px;
+ font-size: 12px;
gap: 4px;
- padding: 3px 8px;
+ padding: 4px 8px;
+`;
+
+const ModelChipChevron = styled.span`
+ align-items: center;
+ color: ${VISUAL_TOKENS.font.color.tertiary};
+ display: flex;
`;
const SendBtn = styled.span`
@@ -258,208 +282,271 @@ const SendBtn = styled.span`
border-radius: 50%;
color: ${VISUAL_TOKENS.font.color.tertiary};
display: flex;
- height: 24px;
+ height: 20px;
justify-content: center;
- width: 24px;
+ width: 20px;
`;
type ProductVisualProps = {
activeScene?: number;
+ aiPanelProgress?: number;
+ bleed?: boolean;
+ collaborative?: boolean;
+ compact?: boolean;
+ compactCursorTour?: boolean;
+ cursorActive?: boolean;
+ cursorLayer?: HTMLElement | null;
+ fill?: boolean;
+ panelOnly?: boolean;
+ playbackEnabled?: boolean;
visual: AppPreviewConfig;
};
-export function ProductVisual({ activeScene, visual }: ProductVisualProps) {
+export function ProductVisual({
+ activeScene,
+ aiPanelProgress = 1,
+ bleed = false,
+ collaborative = false,
+ compact = false,
+ compactCursorTour = false,
+ cursorActive = true,
+ cursorLayer,
+ fill = false,
+ panelOnly = false,
+ playbackEnabled = true,
+ visual,
+}: ProductVisualProps) {
const {
activeItem,
- activeLabel,
+ activeItemId,
+ activeItemLabel,
+ activeStepIndex,
+ agentSteps,
+ completedStepCount,
displayPage,
- handleOptionSelect,
- handleSelectLabel,
- handleToggleFolder,
+ favorites,
highlightedItemId,
- isScrollDriven,
openFolderIds,
revealedObjectIds,
- selectedOption,
+ selectPageItem,
+ selectedScene,
streamComplete,
- streamedText,
- workspaceNav,
- } = useProductVisualAutoplay(visual, { externalScene: activeScene });
+ streamedTextVisibleLength,
+ toggleFolder,
+ workspaceEntries,
+ } = useProductVisualAutoplay(visual, {
+ externalScene: activeScene,
+ playbackEnabled,
+ });
- const activeHeader = displayPage?.header;
- const showViewBar =
- displayPage != null &&
- displayPage.type !== 'dashboard' &&
- displayPage.type !== 'record' &&
- displayPage.type !== 'workflow';
+ const aiMessagesRef = useRef(null);
+
+ // Keep the latest agent step / streamed text in view, like a real chat.
+ useEffect(() => {
+ const messages = aiMessagesRef.current;
+
+ if (messages) {
+ messages.scrollTop = messages.scrollHeight;
+ }
+ }, [activeStepIndex, completedStepCount, streamedTextVisibleLength]);
+
+ const heroCursor = useProductHeroCursorAutoplay(
+ collaborative && cursorActive,
+ {
+ mobile: compactCursorTour,
+ },
+ );
+
+ useEffect(() => {
+ if (collaborative) {
+ selectPageItem(heroCursor.pageItemId);
+ }
+ }, [collaborative, heroCursor.pageItemId, selectPageItem]);
+
+ const effectivePage =
+ collaborative && heroCursor.showRecord
+ ? { ...ANTHROPIC_RECORD_PAGE, activeTabLabel: heroCursor.recordTab }
+ : displayPage;
+ const navbarLabel =
+ effectivePage.type === 'record'
+ ? effectivePage.header.title
+ : activeItemLabel;
+ const responseChips = selectedScene.responseChips;
+ const compactWorkflowPage =
+ effectivePage.type === 'workflow' && effectivePage.nodes === undefined;
+ const resolvedDesktopSidebarMode = collaborative
+ ? 'collapsed'
+ : (selectedScene.sidebarMode ?? 'collapsed');
+ const visibleResponseChips = responseChips.slice(
+ 0,
+ MAX_VISIBLE_RESPONSE_CHIPS,
+ );
+ const hiddenResponseChipCount = Math.max(
+ responseChips.length - MAX_VISIBLE_RESPONSE_CHIPS,
+ 0,
+ );
+
+ const previewShell = (
+
+
+
+
+
+
+ Ask AI
+
+
+
+
+
+ {selectedScene.label}
+ {agentSteps.length > 0 ? (
+ 0}
+ completedStepCount={completedStepCount}
+ steps={agentSteps}
+ />
+ ) : null}
+ {streamedTextVisibleLength > 0 ? (
+ <>
+
+ {sliceVisibleParagraphs(
+ selectedScene.responseText,
+ streamedTextVisibleLength,
+ ).map((segments, paragraphIndex) => (
+
+ {segments.map((segment, segmentIndex) =>
+ segment.bold ? (
+
+ {segment.text}
+
+ ) : (
+
+ {segment.text}
+
+ ),
+ )}
+
+ ))}
+
+ {streamComplete && responseChips.length > 0 ? (
+
+ {visibleResponseChips.map((chip) => (
+
+
+ {chip.name}
+
+ ))}
+ {hiddenResponseChipCount > 0 ? (
+
+ +{hiddenResponseChipCount} more
+
+ ) : null}
+
+ ) : null}
+ >
+ ) : agentSteps.length === 0 ? (
+ Thinking...
+ ) : null}
+
+
+
+
+ Ask, search or make anything...
+
+
+
+
+
+
+
+
+ Claude Opus 4.6
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+ workspaceEntries={workspaceEntries}
+ />
+
+ );
+
+ const cursors =
+ collaborative && cursorLayer
+ ? createPortal(
+ HERO_CURSORS.map((cursorConfig, index) => {
+ const isActive = index === heroCursor.activeCursor;
+
+ return (
+
+ );
+ }),
+ cursorLayer,
+ )
+ : null;
return (
-
-
-
-
-
-
-
-
-
-
-
-
- {showViewBar ? (
-
- ) : null}
-
- {displayPage
- ? renderPageDefinition(
- displayPage,
- handleSelectLabel,
- activeItem?.id ?? activeLabel,
- )
- : null}
-
-
-
-
-
-
-
- Ask AI
-
-
-
-
-
- {PROMPT_OPTIONS[selectedOption].label}
- {streamedText ? (
- {streamedText}
- ) : (
- Thinking...
- )}
-
- {streamComplete && !isScrollDriven ? (
-
- {PROMPT_OPTIONS.map((option, index) =>
- index === selectedOption ? null : (
- handleOptionSelect(index)}
- >
- {option.icon}
- {option.label}
-
- ),
- )}
-
- ) : null}
-
-
-
- Ask, search or make anything...
-
-
-
-
-
-
-
-
- Claude Haiku 4.5
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {previewShell}
+ {cursors}
);
}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisualAiSteps.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisualAiSteps.tsx
new file mode 100644
index 0000000000..561bb9128e
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/ProductVisualAiSteps.tsx
@@ -0,0 +1,252 @@
+'use client';
+
+import { useState } from 'react';
+
+import { styled } from '@linaria/react';
+import {
+ IconChecklist,
+ IconChevronRight,
+ IconCpu,
+ IconFilter,
+ IconHierarchy3,
+ IconLayoutList,
+ IconMail,
+ IconNotes,
+ IconSearch,
+} from '@tabler/icons-react';
+
+import { VISUAL_TOKENS } from '@/sections/AppPreview/Shared/utils/app-preview-tokens';
+
+import type { AgentStep, AgentToolIcon } from './product-visual.data';
+
+const TOOL_ICONS: Record = {
+ search: IconSearch,
+ filter: IconFilter,
+ notes: IconNotes,
+ tasks: IconChecklist,
+ record: IconLayoutList,
+ workflow: IconHierarchy3,
+ mail: IconMail,
+};
+
+const StepsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+const StepList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+`;
+
+const StepRow = styled.div`
+ align-items: center;
+ animation: aiStepAppear 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ display: flex;
+ gap: 8px;
+ min-height: 20px;
+
+ @keyframes aiStepAppear {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+const SummaryButton = styled.button`
+ align-items: center;
+ animation: aiSummaryAppear 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ color: ${VISUAL_TOKENS.font.color.tertiary};
+ cursor: pointer;
+ display: flex;
+ font-family: ${VISUAL_TOKENS.font.family};
+ gap: 8px;
+ min-height: 18px;
+ padding: 0;
+ width: fit-content;
+
+ @keyframes aiSummaryAppear {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+const SummaryChevron = styled.span`
+ align-items: center;
+ color: ${VISUAL_TOKENS.font.color.light};
+ display: flex;
+ justify-content: center;
+ transition: transform 150ms ease-in-out;
+`;
+
+const SummaryText = styled.span`
+ color: inherit;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 18px;
+`;
+
+const StepIcon = styled.span`
+ align-items: center;
+ color: ${VISUAL_TOKENS.font.color.light};
+ display: flex;
+ flex-shrink: 0;
+ justify-content: center;
+ min-width: 14px;
+`;
+
+const StepLoaderIcon = styled.span`
+ align-items: center;
+ color: ${VISUAL_TOKENS.font.color.tertiary};
+ display: flex;
+ flex-shrink: 0;
+ justify-content: center;
+ min-width: 14px;
+`;
+
+const StepLabel = styled.span`
+ color: ${VISUAL_TOKENS.font.color.tertiary};
+ font-family: ${VISUAL_TOKENS.font.family};
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 18px;
+`;
+
+function ThinkingOrbitLoader() {
+ return (
+
+
+
+ );
+}
+
+type ProductVisualAiStepsProps = {
+ activeStepIndex: number;
+ answerStarted: boolean;
+ completedStepCount: number;
+ steps: AgentStep[];
+};
+
+export function ProductVisualAiSteps({
+ activeStepIndex,
+ answerStarted,
+ completedStepCount,
+ steps,
+}: ProductVisualAiStepsProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const isThinking = activeStepIndex >= 0;
+ const shouldKeepExpandedBeforeAnswer = !answerStarted;
+ const shouldShowSummaryButton =
+ !isThinking && !shouldKeepExpandedBeforeAnswer;
+ const shouldRenderRows =
+ isThinking || isExpanded || shouldKeepExpandedBeforeAnswer;
+
+ const stepCount = steps.length;
+ const visibleCount =
+ activeStepIndex >= 0 ? activeStepIndex + 1 : completedStepCount;
+ const visibleSteps = steps.slice(0, visibleCount);
+
+ return (
+
+ {shouldShowSummaryButton ? (
+ setIsExpanded((previousValue) => !previousValue)}
+ >
+
+
+
+
+ {stepCount === 1 ? '1 step' : `${stepCount} steps`}
+
+
+ ) : null}
+
+ {shouldRenderRows ? (
+
+ {visibleSteps.map((step, index) => {
+ const isRunning = index === activeStepIndex;
+
+ if (step.kind === 'thinking') {
+ return (
+
+ {isRunning ? (
+
+ ) : (
+
+
+
+ )}
+ {isRunning ? 'Thinking' : 'Thought'}
+
+ );
+ }
+
+ const ToolIcon = TOOL_ICONS[step.icon];
+
+ return (
+
+
+
+
+ {isRunning ? step.running : step.done}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/__tests__/streamed-markdown.test.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/__tests__/streamed-markdown.test.ts
new file mode 100644
index 0000000000..48a0ee510d
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/__tests__/streamed-markdown.test.ts
@@ -0,0 +1,104 @@
+import {
+ getVisibleLength,
+ sliceVisibleParagraphs,
+ type MarkdownSegment,
+} from '../streamed-markdown';
+
+const visibleCharCount = (paragraphs: MarkdownSegment[][]) =>
+ paragraphs.reduce(
+ (total, segments) =>
+ total + segments.reduce((sum, segment) => sum + segment.text.length, 0),
+ 0,
+ );
+
+describe('getVisibleLength', () => {
+ it('counts plain characters', () => {
+ expect(getVisibleLength(['hello'])).toBe(5);
+ });
+
+ it('excludes the ** bold markers', () => {
+ expect(getVisibleLength(['a**bold**c'])).toBe(6);
+ expect(getVisibleLength(['**bold**'])).toBe(4);
+ });
+
+ it('still counts text after an unclosed marker', () => {
+ expect(getVisibleLength(['a**b'])).toBe(2);
+ });
+
+ it('sums across paragraphs', () => {
+ expect(getVisibleLength(['ab', 'cd'])).toBe(4);
+ expect(getVisibleLength([''])).toBe(0);
+ });
+});
+
+describe('sliceVisibleParagraphs', () => {
+ it('returns nothing for a zero-length reveal', () => {
+ expect(sliceVisibleParagraphs(['hello'], 0)).toEqual([]);
+ });
+
+ it('reveals plain text up to the visible length', () => {
+ expect(sliceVisibleParagraphs(['hello'], 3)).toEqual([
+ [{ bold: false, text: 'hel' }],
+ ]);
+ expect(sliceVisibleParagraphs(['hello'], 5)).toEqual([
+ [{ bold: false, text: 'hello' }],
+ ]);
+ });
+
+ it('splits plain and bold runs at full length', () => {
+ expect(sliceVisibleParagraphs(['a**bold**c'], 6)).toEqual([
+ [
+ { bold: false, text: 'a' },
+ { bold: true, text: 'bold' },
+ { bold: false, text: 'c' },
+ ],
+ ]);
+ });
+
+ it('truncates inside a bold run', () => {
+ expect(sliceVisibleParagraphs(['a**bold**c'], 3)).toEqual([
+ [
+ { bold: false, text: 'a' },
+ { bold: true, text: 'bo' },
+ ],
+ ]);
+ });
+
+ it('stops before a bold run when the plain prefix fills the budget', () => {
+ expect(sliceVisibleParagraphs(['a**bold**c'], 1)).toEqual([
+ [{ bold: false, text: 'a' }],
+ ]);
+ });
+
+ it('treats an unclosed marker as a trailing bold run', () => {
+ expect(sliceVisibleParagraphs(['a**b'], 2)).toEqual([
+ [
+ { bold: false, text: 'a' },
+ { bold: true, text: 'b' },
+ ],
+ ]);
+ });
+
+ it('flows the budget across paragraphs and drops untouched ones', () => {
+ expect(sliceVisibleParagraphs(['ab', 'cd'], 3)).toEqual([
+ [{ bold: false, text: 'ab' }],
+ [{ bold: false, text: 'c' }],
+ ]);
+ expect(sliceVisibleParagraphs(['**x**', 'y'], 1)).toEqual([
+ [{ bold: true, text: 'x' }],
+ ]);
+ });
+
+ it('never reveals more visible characters than requested or available', () => {
+ const paragraphs = ['Intro **bold** tail', 'second **line**'];
+ const total = getVisibleLength(paragraphs);
+
+ for (let length = 0; length <= total + 5; length += 1) {
+ const revealed = visibleCharCount(
+ sliceVisibleParagraphs(paragraphs, length),
+ );
+
+ expect(revealed).toBe(Math.min(length, total));
+ }
+ });
+});
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-hero-menu-sync.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-hero-menu-sync.tsx
new file mode 100644
index 0000000000..6555fd9a9e
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-hero-menu-sync.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+import { Menu } from '@/sections/Menu/components';
+import type { MenuScheme, MenuSocialLinkType } from '@/sections/Menu/types';
+
+type ProductHeroMenuState = {
+ backgroundColor: string;
+ disableElevation: boolean;
+ scheme: MenuScheme;
+};
+
+type ProductHeroMenuContextValue = {
+ setMenuState: (state: ProductHeroMenuState) => void;
+};
+
+const ProductHeroMenuContext =
+ createContext(null);
+
+export function useProductHeroMenuSync() {
+ return useContext(ProductHeroMenuContext);
+}
+
+const INITIAL_MENU_STATE: ProductHeroMenuState = {
+ backgroundColor: 'rgba(255, 255, 255, 0)',
+ disableElevation: true,
+ scheme: 'primary',
+};
+
+type ProductHeroMenuSyncProps = {
+ children: ReactNode;
+ socialLinks: MenuSocialLinkType[];
+};
+
+export function ProductHeroMenuSync({
+ children,
+ socialLinks,
+}: ProductHeroMenuSyncProps) {
+ const [menuState, setMenuState] =
+ useState(INITIAL_MENU_STATE);
+
+ const contextValue = useMemo(() => ({ setMenuState }), []);
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-visual.data.tsx b/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
index 7145496f79..5bf174eada 100644
--- a/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/product-visual.data.tsx
@@ -1,4 +1,7 @@
-import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
+import {
+ SHARED_COMPANY_LOGO_URLS,
+ SHARED_PEOPLE_AVATAR_URLS,
+} from '@/content/site/asset-paths';
import type {
RecordPageDefinition,
RowDef,
@@ -27,7 +30,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Anthropic',
domain: 'anthropic.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -52,7 +55,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Slack',
domain: 'slack.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -77,7 +80,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Figma',
domain: 'figma.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -102,7 +105,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Notion',
domain: 'notion.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -127,7 +130,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Github',
domain: 'github.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -152,7 +155,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Airbnb',
domain: 'airbnb.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -177,7 +180,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Stripe',
domain: 'stripe.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -202,7 +205,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Sequoia',
domain: 'sequoia.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -227,7 +230,7 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Accel',
domain: 'accel.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
{
@@ -252,225 +255,270 @@ export const NEW_TASK_ROWS: RowDef[] = [
name: 'Google',
domain: 'google.com',
},
- status: { type: 'tag', value: 'To Do' },
+ status: { type: 'select', value: 'To Do' },
},
},
];
-export const NEW_COMPANY_ROW: RowDef = {
- id: 'openai',
- cells: {
- company: { type: 'entity', name: 'OpenAI', domain: 'openai.com' },
- url: { type: 'link', value: 'openai.com' },
- createdBy: {
- type: 'person',
- name: 'AI Agent',
- tone: 'gray',
- kind: 'system',
- shortLabel: 'AI',
- },
- address: { type: 'text', value: '3180 18th St' },
- accountOwner: {
- type: 'person',
- name: 'Sam Altman',
- tone: 'amber',
- kind: 'person',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
- },
- 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',
- name: 'Sam Altman',
- shortLabel: 'S',
- tone: 'amber',
- kind: 'person',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
- },
- employees: { type: 'number', value: '3,500' },
- opportunities: { type: 'relation', items: [] },
- added: { type: 'text', value: 'Just now' },
- },
+export type ResponseChip = {
+ logoUrl: string;
+ name: string;
};
-export const NEW_PERSON_ROW: RowDef = {
- id: 'sam-altman',
- cells: {
- name: {
- type: 'person',
- name: 'Sam Altman',
- tone: 'amber',
- kind: 'person',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.samAltman,
- },
- 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' },
- },
+export type ProductVisualSceneKind =
+ | 'leadCreation'
+ | 'opportunityReview'
+ | 'taskCreation'
+ | 'dashboardCreation'
+ | 'workflowCreation';
+
+export type AgentToolIcon =
+ | 'search'
+ | 'filter'
+ | 'notes'
+ | 'tasks'
+ | 'record'
+ | 'workflow'
+ | 'mail';
+
+export type AgentStep =
+ | { kind: 'thinking'; durationMs: number }
+ | {
+ kind: 'tool';
+ icon: AgentToolIcon;
+ running: string;
+ done: string;
+ durationMs: number;
+ };
+
+export type ProductVisualSceneDefinition = {
+ initialPageItemId: string;
+ kind: ProductVisualSceneKind;
+ label: string;
+ responseChips: ResponseChip[];
+ responseText: string[];
+ sidebarMode?: 'collapsed' | 'expanded';
+ followUpPageItemId?: string;
+ steps?: AgentStep[];
};
-export const PROMPT_OPTIONS = [
+export const COMPANIES_PAGE_ITEM_ID = 'companies';
+export const PEOPLE_PAGE_ITEM_ID = 'people';
+export const OPPORTUNITIES_PAGE_ITEM_ID = 'opportunities';
+export const TASKS_PAGE_ITEM_ID = 'tasks';
+export const WORKFLOW_EMAIL_SEQUENCE_PAGE_ITEM_ID =
+ 'workflow-send-email-sequence';
+export const SALES_DASHBOARD_PAGE_ITEM_ID = 'sales-dashboard';
+
+export const PRODUCT_VISUAL_SCENES: ProductVisualSceneDefinition[] = [
{
- icon: (
-
- ),
+ initialPageItemId: COMPANIES_PAGE_ITEM_ID,
+ kind: 'leadCreation',
label: 'Add a new lead',
- navSteps: [
- { at: 0.25, target: 'Companies' },
- { at: 0.65, target: 'People' },
+ responseText: [],
+ responseChips: [],
+ sidebarMode: 'expanded',
+ },
+ {
+ initialPageItemId: OPPORTUNITIES_PAGE_ITEM_ID,
+ kind: 'opportunityReview',
+ label: 'Build a pipeline board grouped by stage',
+ responseText: [
+ 'Organized your open deals into a **pipeline board** grouped by stage — **New**, **Screening**, **Meeting**, **Proposal**, and **Customer**.',
+ 'Drag a card to move a deal forward, or open one to see the full history.',
+ ],
+ responseChips: [
+ { name: 'Anthropic', logoUrl: SHARED_COMPANY_LOGO_URLS.anthropic },
+ { name: 'Notion', logoUrl: SHARED_COMPANY_LOGO_URLS.notion },
+ { name: 'Github', logoUrl: SHARED_COMPANY_LOGO_URLS.github },
+ { name: 'Airbnb', logoUrl: SHARED_COMPANY_LOGO_URLS.airbnb },
+ { name: 'Figma', logoUrl: SHARED_COMPANY_LOGO_URLS.figma },
+ { name: 'Stripe', logoUrl: SHARED_COMPANY_LOGO_URLS.stripe },
+ { name: 'Mailchimp', logoUrl: SHARED_COMPANY_LOGO_URLS.mailchimp },
+ ],
+ sidebarMode: 'collapsed',
+ steps: [
+ { kind: 'thinking', durationMs: 1200 },
+ {
+ kind: 'tool',
+ icon: 'search',
+ running: 'Reading your open deals',
+ done: 'Read 24 deals',
+ durationMs: 1000,
+ },
+ {
+ kind: 'tool',
+ icon: 'record',
+ running: 'Building the pipeline board',
+ done: 'Built the board',
+ durationMs: 800,
+ },
],
- response:
- 'Adding OpenAI as a new company. Setting domain to openai.com, industry to AI Research, and ARR to $2,000,000. Account owner assigned to Sam Altman. Company record is live in your CRM.\n\nNow creating the contact — adding Sam Altman as CEO at OpenAI, based in San Francisco. Person record linked to the company.',
},
{
- icon: (
-
- ),
- label: 'Show me all deals closing this month',
- navSteps: [{ at: 0.3, target: 'Opportunities' }],
- response:
- '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.',
+ initialPageItemId: TASKS_PAGE_ITEM_ID,
+ kind: 'taskCreation',
+ label:
+ 'Generate follow-up tasks for my top 10 accounts using notes to gather context',
+ responseText: [
+ 'Created **10 follow-up tasks** dated **Nov 1 through Nov 8**.',
+ 'The first rows cover Anthropic, Slack, Figma, Notion, and Github, followed by Airbnb, Stripe, Sequoia, Accel, and Google.',
+ ],
+ responseChips: [
+ { name: 'Anthropic', logoUrl: SHARED_COMPANY_LOGO_URLS.anthropic },
+ { name: 'Slack', logoUrl: SHARED_COMPANY_LOGO_URLS.slack },
+ { name: 'Figma', logoUrl: SHARED_COMPANY_LOGO_URLS.figma },
+ { name: 'Notion', logoUrl: SHARED_COMPANY_LOGO_URLS.notion },
+ { name: 'Github', logoUrl: SHARED_COMPANY_LOGO_URLS.github },
+ { name: 'Airbnb', logoUrl: SHARED_COMPANY_LOGO_URLS.airbnb },
+ { name: 'Stripe', logoUrl: SHARED_COMPANY_LOGO_URLS.stripe },
+ { name: 'Sequoia', logoUrl: SHARED_COMPANY_LOGO_URLS.sequoia },
+ { name: 'Accel', logoUrl: SHARED_COMPANY_LOGO_URLS.accel },
+ { name: 'Google', logoUrl: SHARED_COMPANY_LOGO_URLS.google },
+ ],
+ sidebarMode: 'collapsed',
+ steps: [
+ { kind: 'thinking', durationMs: 1200 },
+ {
+ kind: 'tool',
+ icon: 'notes',
+ running: 'Reading notes on your top 10 accounts',
+ done: 'Read 10 accounts',
+ durationMs: 1100,
+ },
+ {
+ kind: 'tool',
+ icon: 'tasks',
+ running: 'Creating 10 follow-up tasks',
+ done: 'Created 10 tasks',
+ durationMs: 900,
+ },
+ ],
},
{
- 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.',
+ initialPageItemId: SALES_DASHBOARD_PAGE_ITEM_ID,
+ kind: 'dashboardCreation',
+ label: 'Build a dashboard of pipeline by stage and ARR',
+ responseText: [
+ 'Built a **Sales dashboard** with live KPIs — **$12.9M pipeline**, **$2.4M won this quarter**, and a **38% win rate** — plus charts for deals by stage and ARR over time.',
+ 'It refreshes automatically as your data changes.',
+ ],
+ responseChips: [],
+ sidebarMode: 'collapsed',
+ steps: [
+ { kind: 'thinking', durationMs: 1200 },
+ {
+ kind: 'tool',
+ icon: 'search',
+ running: 'Aggregating pipeline and revenue',
+ done: 'Aggregated 24 deals',
+ durationMs: 1000,
+ },
+ {
+ kind: 'tool',
+ icon: 'record',
+ running: 'Assembling the dashboard',
+ done: 'Built the dashboard',
+ durationMs: 900,
+ },
+ ],
},
{
- 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: (
-
- ),
- label: 'Create a workflow that sends an email sequence',
- navSteps: [{ at: 0.4, target: 'Send email sequence when deal is engaged' }],
- response:
- "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.",
+ initialPageItemId: WORKFLOW_EMAIL_SEQUENCE_PAGE_ITEM_ID,
+ kind: 'workflowCreation',
+ label: 'Draft a workflow that sends an email sequence',
+ responseText: [
+ 'Created and activated a sequence with a **Manual trigger**, an **Iterator**, and a **Send Email** step.',
+ 'It is ready to run now, and filters or email copy can be refined next.',
+ ],
+ responseChips: [],
+ sidebarMode: 'collapsed',
+ steps: [
+ { kind: 'thinking', durationMs: 1200 },
+ {
+ kind: 'tool',
+ icon: 'workflow',
+ running: 'Designing the workflow',
+ done: 'Designed 3 steps',
+ durationMs: 1000,
+ },
+ {
+ kind: 'tool',
+ icon: 'mail',
+ running: 'Activating the sequence',
+ done: 'Activated sequence',
+ durationMs: 800,
+ },
+ ],
},
];
-export const QONTO_RECORD_PAGE: RecordPageDefinition = {
+export const ANTHROPIC_RECORD_PAGE: RecordPageDefinition = {
type: 'record',
header: {
- title: 'Qonto',
+ title: 'Anthropic',
count: 12,
},
record: {
- logoDomain: 'qonto.com',
- name: 'Qonto',
+ logoDomain: 'anthropic.com',
+ name: 'Anthropic',
createdAt: 'Created 4 hours ago',
fields: [
- { icon: 'link', label: 'URL', value: 'qonto.com' },
+ {
+ icon: 'link',
+ label: 'URL',
+ value: { type: 'link', kind: 'url', value: 'anthropic.com' },
+ },
{
icon: 'user',
- label: 'Account O...',
- value: 'Phil Schiller',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.philSchiller,
+ label: 'Account Owner',
+ value: {
+ type: 'person',
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ kind: 'person',
+ tone: 'gray',
+ },
},
{
icon: 'mapPin',
label: 'Address',
- value: '18 Rue De Navarin, 75009 Paris',
+ value: { type: 'text', value: '18 Rue De Navarin' },
+ },
+ {
+ icon: 'check',
+ label: 'ICP',
+ value: { type: 'boolean', value: true },
+ },
+ {
+ icon: 'currency',
+ label: 'ARR',
+ value: { type: 'currency', value: '$500,000' },
},
- { icon: 'check', label: 'ICP', value: '✓ True' },
- { icon: 'currency', label: 'Revenue', value: '$500,000' },
{
icon: 'linkedin',
- label: 'Linkedin',
- value: 'linkedin.com/company/q...',
+ label: 'LinkedIn',
+ value: {
+ type: 'link',
+ kind: 'social',
+ label: 'linkedin.com/company/a...',
+ value: 'anthropic',
+ },
},
- { 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' }],
+ items: [{ name: 'Enterprise Expansion', domain: 'anthropic.com' }],
},
{
title: 'People',
count: 12,
items: [
{
- name: 'Alexandre',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
- },
- {
- name: 'Steve Anavi',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.steveAnavi,
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
},
],
},
@@ -478,23 +526,206 @@ export const QONTO_RECORD_PAGE: RecordPageDefinition = {
},
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.',
+ id: 'kickoff',
+ title: 'Kickoff with Dario',
+ body: 'Walked through the enterprise expansion plan and the security review timeline. Anthropic wants SSO, audit logs, and a dedicated environment before rolling out to the wider research org.',
relation: {
- name: 'Alexandre',
- avatarUrl: SHARED_PEOPLE_AVATAR_URLS.alexandreProt,
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
},
},
{
- 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: 'pricing-follow-up',
+ title: 'Follow-up on pricing',
+ body: 'Shared updated seat pricing and the annual commitment options. Next step is a procurement intro and a technical deep-dive with their platform team.',
+ },
+ ],
+ timeline: [
+ {
+ kind: 'calendar',
+ id: 'calendar-security-review',
+ actor: 'Alice',
+ title: 'Security review',
+ detail: 'Tomorrow · 10:00 – 10:45 AM · Dario Amodei, Alice',
+ time: '1 hour ago',
},
{
- 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.',
+ kind: 'note',
+ id: 'note-kickoff',
+ actor: 'Alice',
+ title: 'Kickoff with Dario',
+ time: '2 hours ago',
+ },
+ {
+ kind: 'updated',
+ id: 'update-multi',
+ actor: 'Alice',
+ record: 'Anthropic',
+ time: '3 hours ago',
+ diffs: [
+ { label: 'Industry', value: { type: 'select', value: 'AI Research' } },
+ { label: 'Employees', value: { type: 'text', value: '612' } },
+ { label: 'ICP', value: { type: 'boolean', value: true } },
+ ],
+ },
+ {
+ kind: 'updated',
+ id: 'update-arr',
+ actor: 'Alice',
+ record: 'Anthropic',
+ time: '3 hours ago',
+ diffs: [{ label: 'ARR', value: { type: 'currency', value: '$500,000' } }],
+ },
+ {
+ kind: 'created',
+ id: 'record-created',
+ subject: 'Anthropic',
+ actor: 'Dario Amodei',
+ time: '4 hours ago',
+ },
+ ],
+ tasks: [
+ {
+ id: 'task-agreement',
+ title: 'Send enterprise agreement',
+ body: 'Final redlines from legal',
+ due: 'Tomorrow',
+ target: {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ },
+ {
+ id: 'task-security',
+ title: 'Complete security review',
+ body: 'SOC 2 and DPA sign-off',
+ due: 'Friday',
+ done: true,
+ target: { name: 'Enterprise Expansion', domain: 'anthropic.com' },
+ },
+ {
+ id: 'task-procurement',
+ title: 'Schedule procurement intro',
+ body: 'Loop in their procurement lead',
+ due: 'Next week',
+ target: { name: 'Alice', tone: 'amber' },
+ },
+ ],
+ files: [
+ {
+ id: 'file-agreement',
+ name: 'Enterprise Agreement.pdf',
+ category: 'pdf',
+ date: '2 hours ago',
+ },
+ {
+ id: 'file-security',
+ name: 'Security Review.xlsx',
+ category: 'sheet',
+ date: '1 day ago',
+ },
+ {
+ id: 'file-pricing',
+ name: 'Pricing Proposal.pdf',
+ category: 'pdf',
+ date: '3 days ago',
+ },
+ ],
+ emails: [
+ {
+ id: 'email-expansion',
+ participants: [
+ {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ { name: 'Alice', tone: 'amber' },
+ ],
+ count: 3,
+ subject: 'Re: Enterprise expansion',
+ body: 'Thanks for the detailed proposal — looping in our platform team.',
+ date: '2 hours ago',
+ },
+ {
+ id: 'email-compliance',
+ participants: [
+ { name: 'Alice', tone: 'amber' },
+ {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ ],
+ count: 2,
+ subject: 'Security & compliance docs',
+ body: 'Attaching the SOC 2 report and our DPA for review.',
+ date: '1 day ago',
+ },
+ {
+ id: 'email-procurement',
+ participants: [
+ {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ ],
+ count: 1,
+ subject: 'Intro to procurement',
+ body: 'Connecting you with our procurement lead to move the contract forward.',
+ date: '2 days ago',
+ },
+ ],
+ calendar: [
+ {
+ id: 'cal-day-wed',
+ weekday: 'Wed',
+ day: '12',
+ events: [
+ {
+ id: 'cal-security',
+ start: '10:00',
+ end: '10:45',
+ title: 'Security review',
+ attending: true,
+ participants: [
+ {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ { name: 'Alice', tone: 'amber' },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'cal-day-fri',
+ weekday: 'Fri',
+ day: '14',
+ events: [
+ {
+ id: 'cal-qbr',
+ start: '14:00',
+ end: '15:00',
+ title: 'Quarterly business review',
+ participants: [
+ {
+ name: 'Dario Amodei',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.darioAmodei,
+ tone: 'gray',
+ },
+ { name: 'Alice', tone: 'amber' },
+ {
+ name: 'Marcus Lee',
+ avatarUrl: SHARED_PEOPLE_AVATAR_URLS.anonymousMike,
+ tone: 'amber',
+ },
+ ],
+ },
+ ],
},
],
};
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/streamed-markdown.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/streamed-markdown.ts
new file mode 100644
index 0000000000..9c0ca37b63
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/streamed-markdown.ts
@@ -0,0 +1,127 @@
+// Incremental "**bold**" rendering for the streamed AI answer. The stream target
+// (how many characters to reveal) and the render (which characters are shown)
+// both depend on one definition of a "visible character" — anything except the
+// `**` markers — so the two can never drift out of sync.
+
+export type MarkdownSegment = {
+ bold: boolean;
+ text: string;
+};
+
+// Rendered character count, excluding the `**` bold markers.
+export function getVisibleLength(paragraphs: string[]): number {
+ return paragraphs.reduce(
+ (total, paragraph) => total + getParagraphVisibleLength(paragraph),
+ 0,
+ );
+}
+
+function getParagraphVisibleLength(text: string): number {
+ let length = 0;
+
+ for (let index = 0; index < text.length; index += 1) {
+ if (text[index] === '*' && text[index + 1] === '*') {
+ index += 1;
+ continue;
+ }
+
+ length += 1;
+ }
+
+ return length;
+}
+
+// Reveal the first `visibleLength` visible characters across `paragraphs`,
+// returning each non-empty paragraph as a run of bold/plain segments.
+export function sliceVisibleParagraphs(
+ paragraphs: string[],
+ visibleLength: number,
+): MarkdownSegment[][] {
+ const result: MarkdownSegment[][] = [];
+ let visibleRemaining = visibleLength;
+
+ for (const paragraph of paragraphs) {
+ if (visibleRemaining <= 0) {
+ break;
+ }
+
+ const { consumed, segments } = sliceParagraph(paragraph, visibleRemaining);
+
+ if (segments.length > 0) {
+ result.push(segments);
+ }
+
+ visibleRemaining -= consumed;
+ }
+
+ return result;
+}
+
+function sliceParagraph(
+ text: string,
+ visibleLength: number,
+): { consumed: number; segments: MarkdownSegment[] } {
+ const segments: MarkdownSegment[] = [];
+ let visibleRemaining = visibleLength;
+ let cursor = 0;
+
+ while (cursor < text.length && visibleRemaining > 0) {
+ const openIndex = text.indexOf('**', cursor);
+
+ if (openIndex === -1) {
+ const segment = text.slice(cursor, cursor + visibleRemaining);
+ segments.push({ bold: false, text: segment });
+ visibleRemaining -= segment.length;
+ break;
+ }
+
+ if (openIndex > cursor) {
+ const plainSegment = text.slice(cursor, openIndex);
+ const visiblePlainSegment = plainSegment.slice(0, visibleRemaining);
+
+ if (visiblePlainSegment.length > 0) {
+ segments.push({ bold: false, text: visiblePlainSegment });
+ visibleRemaining -= visiblePlainSegment.length;
+ }
+
+ if (visiblePlainSegment.length < plainSegment.length) {
+ break;
+ }
+ }
+
+ const closeIndex = text.indexOf('**', openIndex + 2);
+
+ if (closeIndex === -1) {
+ const trailingSegment = text.slice(
+ openIndex + 2,
+ openIndex + 2 + visibleRemaining,
+ );
+
+ if (trailingSegment.length > 0) {
+ segments.push({ bold: true, text: trailingSegment });
+ visibleRemaining -= trailingSegment.length;
+ }
+
+ break;
+ }
+
+ const boldSegment = text.slice(openIndex + 2, closeIndex);
+ const visibleBoldSegment = boldSegment.slice(0, visibleRemaining);
+
+ // Skip an empty run when the budget ends exactly at the marker (the old
+ // inline renderer emitted an invisible empty here).
+ if (visibleBoldSegment.length > 0) {
+ segments.push({ bold: true, text: visibleBoldSegment });
+ }
+
+ visibleRemaining -= visibleBoldSegment.length;
+
+ if (visibleBoldSegment.length < boldSegment.length) {
+ break;
+ }
+
+ cursor = closeIndex + 2;
+ }
+
+ return { consumed: visibleLength - visibleRemaining, segments };
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
index 08ddf0f395..46bdb0de1d 100644
--- a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-hero-scroll-progress.ts
@@ -1,36 +1,101 @@
-import { type RefObject, useEffect, useState } from 'react';
+'use client';
-const TRANSITION_POINT = 0.25;
-const MOBILE_BREAKPOINT = 921;
+import { type RefObject, useCallback, useEffect, useState } from 'react';
+
+const NAV_HEIGHT = 64;
+
+const MORPH_START = 0;
+const MORPH_END = 0.55;
+
+function smoothstep(value: number): number {
+ return value * value * (3 - 2 * value);
+}
+
+function mapToMorphProgress(scrollProgress: number): number {
+ if (scrollProgress <= MORPH_START) return 0;
+ if (scrollProgress >= MORPH_END) return 1;
+
+ const linear = (scrollProgress - MORPH_START) / (MORPH_END - MORPH_START);
+
+ return smoothstep(linear);
+}
+
+export type HeroScrollState = {
+ menuBackground: string;
+ menuElevated: boolean;
+ morphProgress: number;
+ navProgress: number;
+};
+
+const INITIAL_STATE: HeroScrollState = {
+ menuBackground: 'rgb(255, 255, 255)',
+ menuElevated: true,
+ morphProgress: 0,
+ navProgress: 0,
+};
export function useHeroScrollProgress(
trackRef: RefObject,
-): number {
- const [phase, setPhase] = useState(0);
+): HeroScrollState {
+ const [state, setState] = useState(INITIAL_STATE);
- useEffect(() => {
+ const handleScroll = useCallback(() => {
const element = trackRef.current;
+
if (!element) return;
- if (window.innerWidth < MOBILE_BREAKPOINT) return;
+ const rect = element.getBoundingClientRect();
+ const scrollableDistance = element.offsetHeight - window.innerHeight;
- const handleScroll = () => {
- const rect = element.getBoundingClientRect();
- const scrollableDistance = element.offsetHeight - window.innerHeight;
+ if (scrollableDistance <= 0) return;
- if (scrollableDistance <= 0) return;
+ const scrolled = -rect.top;
+ const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
+ const morphProgress = mapToMorphProgress(progress);
- const scrolled = -rect.top;
- const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
+ let navProgress = 0;
- setPhase(progress >= TRANSITION_POINT ? 1 : 0);
- };
+ const wipeLineY = window.innerHeight * (1 - morphProgress);
+ const trackBottom = rect.bottom;
- window.addEventListener('scroll', handleScroll, { passive: true });
- handleScroll();
+ if (wipeLineY <= 0) {
+ navProgress = 1;
+ } else if (wipeLineY < NAV_HEIGHT) {
+ navProgress = smoothstep(1 - wipeLineY / NAV_HEIGHT);
+ }
- return () => window.removeEventListener('scroll', handleScroll);
+ if (trackBottom <= 0) {
+ navProgress = 0;
+ } else if (trackBottom < NAV_HEIGHT) {
+ navProgress *= smoothstep(trackBottom / NAV_HEIGHT);
+ }
+
+ const isCrossing =
+ morphProgress < 1 && wipeLineY <= NAV_HEIGHT && trackBottom > NAV_HEIGHT;
+
+ const channel = Math.round(255 + (20 - 255) * navProgress);
+ const menuBackground = isCrossing
+ ? 'transparent'
+ : `rgb(${channel}, ${channel}, ${channel})`;
+
+ setState({
+ menuBackground,
+ menuElevated: navProgress < 0.02 && !isCrossing,
+ morphProgress,
+ navProgress,
+ });
}, [trackRef]);
- return phase;
+ useEffect(() => {
+ handleScroll();
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ window.addEventListener('resize', handleScroll);
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ window.removeEventListener('resize', handleScroll);
+ };
+ }, [handleScroll]);
+
+ return state;
}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-background-halftone.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-background-halftone.ts
new file mode 100644
index 0000000000..fc2a335dfb
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-background-halftone.ts
@@ -0,0 +1,458 @@
+'use client';
+
+import { createAnimationFrameLoop } from '@/lib/animation';
+import { observeElementSize } from '@/lib/dom/observe-element-size';
+import {
+ createVisualRenderLoop,
+ loadVisualImage,
+ tryCreateSiteWebGlRenderer,
+ type VisualRenderLoop,
+ type VisualRenderLoopFrame,
+} from '@/lib/visual-runtime';
+import { useEffect, useState, type RefObject } from 'react';
+import * as THREE from 'three';
+
+const VIRTUAL_RENDER_HEIGHT = 800;
+
+const HALFTONE_TILE_SIZE = 8;
+const HALFTONE_POWER = 0.1;
+const HALFTONE_WIDTH = 0.52;
+const HALFTONE_CONTRAST = 0.95;
+const HALFTONE_DASH_COLOR = '#ffffff';
+const HALFTONE_HOVER_COLOR = '#ffffff';
+const HALFTONE_HOVER_LIGHT_INTENSITY = 0.8;
+const HALFTONE_HOVER_LIGHT_RADIUS = 0.14;
+const HALFTONE_HOVER_VERTICAL_FADE = 0.5;
+const HALFTONE_HOVER_FADE_IN = 18;
+const HALFTONE_HOVER_FADE_OUT = 7;
+
+const IMAGE_POINTER_FOLLOW = 0.38;
+
+const passThroughVertexShader = `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ gl_Position = vec4(position, 1.0);
+ }
+`;
+
+const imagePassthroughFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tImage;
+ uniform vec2 imageSize;
+ uniform vec2 viewportSize;
+ uniform float contrast;
+
+ varying vec2 vUv;
+
+ void main() {
+ float imageAspect = imageSize.x / imageSize.y;
+ float viewAspect = viewportSize.x / viewportSize.y;
+
+ vec2 uv = vUv - 0.5;
+ float coverRatio = imageAspect / viewAspect;
+ if (coverRatio > 1.0) {
+ uv.x /= coverRatio;
+ } else {
+ uv.y *= coverRatio;
+ }
+ uv += 0.5;
+
+ float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
+ * step(0.0, uv.y) * step(uv.y, 1.0);
+
+ vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
+ vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
+
+ gl_FragColor = vec4(contrastColor, inBounds);
+ }
+`;
+
+const halftoneFragmentShader = `
+ precision highp float;
+
+ uniform sampler2D tScene;
+ uniform vec2 effectResolution;
+ uniform vec2 logicalResolution;
+ uniform float tile;
+ uniform float s_3;
+ uniform float s_4;
+ uniform vec3 dashColor;
+ uniform vec3 hoverDashColor;
+ uniform vec2 interactionUv;
+ uniform float hoverLightStrength;
+ uniform float hoverLightRadius;
+ uniform float hoverVerticalFade;
+
+ varying vec2 vUv;
+
+ float distSegment(in vec2 p, in vec2 a, in vec2 b) {
+ vec2 pa = p - a;
+ vec2 ba = b - a;
+ float denom = max(dot(ba, ba), 0.000001);
+ float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
+ return length(pa - ba * h);
+ }
+
+ float lineSimpleEt(in vec2 p, in float r, in float thickness) {
+ vec2 a = vec2(0.5) + vec2(-r, 0.0);
+ vec2 b = vec2(0.5) + vec2(r, 0.0);
+ float distToSegment = distSegment(p, a, b);
+ float halfThickness = thickness * r;
+ return distToSegment - halfThickness;
+ }
+
+ void main() {
+ // Only draw within the image footprint.
+ vec4 boundsCheck = texture2D(tScene, vUv);
+ if (boundsCheck.a < 0.01) {
+ gl_FragColor = vec4(0.0);
+ return;
+ }
+
+ vec2 fragCoord =
+ (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
+ float halftoneSize = max(tile, 1.0);
+ vec2 pointerPx = interactionUv * logicalResolution;
+ vec2 fragDelta = fragCoord - pointerPx;
+ float fragDist = length(fragDelta);
+
+ float hoverLightMask = 0.0;
+ if (hoverLightStrength > 0.0) {
+ float lightRadiusPx = hoverLightRadius * logicalResolution.y;
+ hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
+ float fadeRange = max(hoverVerticalFade, 0.0001);
+ float verticalHoverFade =
+ smoothstep(0.0, fadeRange, vUv.y) *
+ smoothstep(0.0, fadeRange, 1.0 - vUv.y);
+ hoverLightMask *= verticalHoverFade;
+ }
+
+ vec2 cellIndex = floor(fragCoord / halftoneSize);
+ vec2 sampleUv = clamp(
+ (cellIndex + 0.5) * halftoneSize / logicalResolution,
+ vec2(0.0),
+ vec2(1.0)
+ );
+ vec2 cellUv = fract(fragCoord / halftoneSize);
+
+ vec4 sceneSample = texture2D(tScene, sampleUv);
+ float mask = smoothstep(0.02, 0.08, sceneSample.a);
+ float localPower = clamp(s_3, -1.5, 1.5);
+ float localWidth = clamp(s_4, 0.05, 1.4);
+ float lightLift = hoverLightStrength * hoverLightMask * 0.22;
+ float toneValue =
+ (sceneSample.r + sceneSample.g + sceneSample.b) * (1.0 / 3.0);
+ float bandRadius = clamp(
+ toneValue + localPower * length(vec2(0.5)) * (1.0 / 3.0) + lightLift,
+ 0.0,
+ 1.0
+ ) * 1.86 * 0.5;
+
+ float alpha = 0.0;
+ if (bandRadius > 0.0001) {
+ float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);
+ float edge = 0.02;
+ alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
+ }
+
+ vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverLightMask);
+ vec3 color = activeDashColor * alpha;
+ gl_FragColor = vec4(color, alpha);
+
+ #include
+ #include
+ }
+`;
+
+function createRenderTarget(width: number, height: number) {
+ return new THREE.WebGLRenderTarget(width, height, {
+ format: THREE.RGBAFormat,
+ magFilter: THREE.LinearFilter,
+ minFilter: THREE.LinearFilter,
+ });
+}
+
+type PointerState = {
+ hoverStrength: number;
+ mouseX: number;
+ mouseY: number;
+ pointerInside: boolean;
+ smoothedMouseX: number;
+ smoothedMouseY: number;
+};
+
+async function mountProductBackgroundCanvas({
+ container,
+ imageUrl,
+ dashColor = HALFTONE_DASH_COLOR,
+ hoverColor = HALFTONE_HOVER_COLOR,
+}: {
+ container: HTMLDivElement;
+ imageUrl: string;
+ dashColor?: string;
+ hoverColor?: string;
+}): Promise<() => void> {
+ const image = await loadVisualImage(imageUrl, {
+ label: 'product background image',
+ });
+
+ const getWidth = () => Math.max(container.clientWidth, 1);
+ const getHeight = () => Math.max(container.clientHeight, 1);
+ const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
+ const getVirtualWidth = () =>
+ Math.max(
+ Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
+ 1,
+ );
+
+ let renderLoop: VisualRenderLoop | null = null;
+ const renderer = tryCreateSiteWebGlRenderer({
+ alpha: true,
+ antialias: false,
+ onContextLost: () => {
+ renderLoop?.stop();
+ },
+ powerPreference: 'high-performance',
+ });
+
+ if (renderer === null) {
+ return () => {};
+ }
+
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.setPixelRatio(1);
+ renderer.setClearColor(0x000000, 0);
+ renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
+
+ const canvas = renderer.domElement;
+ canvas.setAttribute('aria-hidden', 'true');
+ canvas.style.display = 'block';
+ canvas.style.height = '100%';
+ canvas.style.pointerEvents = 'none';
+ canvas.style.width = '100%';
+ container.appendChild(canvas);
+
+ const imageTexture = new THREE.Texture(image);
+ imageTexture.colorSpace = THREE.SRGBColorSpace;
+ imageTexture.generateMipmaps = false;
+ imageTexture.magFilter = THREE.LinearFilter;
+ imageTexture.minFilter = THREE.LinearFilter;
+ imageTexture.needsUpdate = true;
+
+ const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
+ const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
+ const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+
+ const imageMaterial = new THREE.ShaderMaterial({
+ fragmentShader: imagePassthroughFragmentShader,
+ uniforms: {
+ contrast: { value: HALFTONE_CONTRAST },
+ imageSize: { value: new THREE.Vector2(image.width, image.height) },
+ tImage: { value: imageTexture },
+ viewportSize: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ },
+ vertexShader: passThroughVertexShader,
+ });
+
+ const imageScene = new THREE.Scene();
+ imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
+
+ const halftoneMaterial = new THREE.ShaderMaterial({
+ fragmentShader: halftoneFragmentShader,
+ transparent: true,
+ uniforms: {
+ dashColor: { value: new THREE.Color(dashColor) },
+ effectResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ hoverDashColor: { value: new THREE.Color(hoverColor) },
+ hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS },
+ hoverLightStrength: { value: 0 },
+ hoverVerticalFade: { value: HALFTONE_HOVER_VERTICAL_FADE },
+ interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
+ logicalResolution: {
+ value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
+ },
+ s_3: { value: HALFTONE_POWER },
+ s_4: { value: HALFTONE_WIDTH },
+ tScene: { value: sceneTarget.texture },
+ tile: { value: HALFTONE_TILE_SIZE },
+ },
+ vertexShader: passThroughVertexShader,
+ });
+
+ const postScene = new THREE.Scene();
+ postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
+
+ const syncSize = () => {
+ const virtualWidth = getVirtualWidth();
+ const virtualHeight = getVirtualHeight();
+
+ renderer.setSize(virtualWidth, virtualHeight, false);
+ sceneTarget.setSize(virtualWidth, virtualHeight);
+ halftoneMaterial.uniforms.effectResolution.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ halftoneMaterial.uniforms.logicalResolution.value.set(
+ virtualWidth,
+ virtualHeight,
+ );
+ imageMaterial.uniforms.viewportSize.value.set(virtualWidth, virtualHeight);
+ };
+
+ const stopObservingSize = observeElementSize(container, syncSize);
+
+ const pointer: PointerState = {
+ hoverStrength: 0,
+ mouseX: 0.5,
+ mouseY: 0.5,
+ pointerInside: false,
+ smoothedMouseX: 0.5,
+ smoothedMouseY: 0.5,
+ };
+
+ const updatePointerPosition = (event: PointerEvent) => {
+ const rect = container.getBoundingClientRect();
+ const width = Math.max(rect.width, 1);
+ const height = Math.max(rect.height, 1);
+
+ pointer.mouseX = (event.clientX - rect.left) / width;
+ pointer.mouseY = (event.clientY - rect.top) / height;
+ pointer.pointerInside = true;
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ updatePointerPosition(event);
+ };
+
+ const handlePointerLeave = () => {
+ pointer.pointerInside = false;
+ };
+
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerleave', handlePointerLeave);
+ window.addEventListener('blur', handlePointerLeave);
+
+ const renderFrame = (
+ _timestamp: DOMHighResTimeStamp,
+ { deltaSeconds }: VisualRenderLoopFrame,
+ ) => {
+ const hoverEasing =
+ 1 -
+ Math.exp(
+ -deltaSeconds *
+ (pointer.pointerInside
+ ? HALFTONE_HOVER_FADE_IN
+ : HALFTONE_HOVER_FADE_OUT),
+ );
+ pointer.hoverStrength +=
+ ((pointer.pointerInside ? 1 : 0) - pointer.hoverStrength) * hoverEasing;
+
+ pointer.smoothedMouseX +=
+ (pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
+ pointer.smoothedMouseY +=
+ (pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
+
+ halftoneMaterial.uniforms.interactionUv.value.set(
+ pointer.smoothedMouseX,
+ 1 - pointer.smoothedMouseY,
+ );
+ halftoneMaterial.uniforms.hoverLightStrength.value =
+ HALFTONE_HOVER_LIGHT_INTENSITY * pointer.hoverStrength;
+
+ renderer.setRenderTarget(sceneTarget);
+ renderer.render(imageScene, orthographicCamera);
+
+ renderer.setRenderTarget(null);
+ renderer.clear();
+ renderer.render(postScene, orthographicCamera);
+ };
+
+ renderLoop = createVisualRenderLoop({
+ renderFrame,
+ target: container,
+ targetVisibilityOptions: { rootMargin: '100px' },
+ });
+ renderLoop.start();
+
+ return () => {
+ renderLoop?.dispose();
+ stopObservingSize();
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerleave', handlePointerLeave);
+ window.removeEventListener('blur', handlePointerLeave);
+ halftoneMaterial.dispose();
+ imageMaterial.dispose();
+ imageTexture.dispose();
+ fullScreenGeometry.dispose();
+ sceneTarget.dispose();
+ renderer.dispose();
+
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+}
+
+export function useProductBackgroundHalftone({
+ imageUrl,
+ dashColor,
+ hoverColor,
+ mountRef,
+}: {
+ imageUrl: string;
+ dashColor?: string;
+ hoverColor?: string;
+ mountRef: RefObject;
+}) {
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ const container = mountRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ let disposed = false;
+ let unmount: (() => void) | null = null;
+ const readyTask = createAnimationFrameLoop({
+ onFrame: () => {
+ setIsReady(true);
+ return false;
+ },
+ });
+
+ mountProductBackgroundCanvas({
+ container,
+ imageUrl,
+ dashColor,
+ hoverColor,
+ })
+ .then((dispose) => {
+ if (disposed) {
+ dispose();
+ return;
+ }
+ unmount = dispose;
+ readyTask.start();
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+
+ return () => {
+ disposed = true;
+ readyTask.stop();
+ unmount?.();
+ };
+ }, [mountRef, imageUrl, dashColor, hoverColor]);
+
+ return isReady;
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-hero-cursor-autoplay.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-hero-cursor-autoplay.ts
new file mode 100644
index 0000000000..d5707b74a2
--- /dev/null
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-hero-cursor-autoplay.ts
@@ -0,0 +1,334 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+export type CursorTarget =
+ | { kind: 'home' }
+ | { kind: 'row'; id: string }
+ | { kind: 'rail'; id: string }
+ | { kind: 'tab'; id: string };
+
+export type ProductHeroTourState = {
+ activeCursor: number;
+ clicking: boolean;
+ glideMs?: number;
+ hidden: boolean;
+ pageItemId: string;
+ recordTab?: string;
+ showRecord: boolean;
+ target: CursorTarget;
+};
+
+type TourPhase = {
+ activeCursor: number;
+ clicking: boolean;
+ durationMs: number;
+ glideMs?: number;
+ hidden: boolean;
+ pageItemId: string;
+ recordTab?: string;
+ showRecord: boolean;
+ target: CursorTarget;
+};
+
+const COMPANIES = 'companies';
+const PEOPLE = 'people';
+const NOTES = 'notes';
+const RECORD_ROW_ID = 'anthropic';
+
+const LOOP_START_INDEX = 0;
+
+// Desktop tour — explores the record's Timeline/Notes/Calendar tabs.
+const DESKTOP_PHASES: TourPhase[] = [
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 1400,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'row', id: RECORD_ROW_ID },
+ clicking: false,
+ hidden: false,
+ durationMs: 1000,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'row', id: RECORD_ROW_ID },
+ clicking: true,
+ hidden: false,
+ durationMs: 400,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Timeline',
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 800,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Timeline',
+ target: { kind: 'tab', id: 'Notes' },
+ clicking: false,
+ hidden: false,
+ durationMs: 900,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Notes',
+ target: { kind: 'tab', id: 'Notes' },
+ clicking: true,
+ hidden: false,
+ durationMs: 450,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Notes',
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 800,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Notes',
+ target: { kind: 'tab', id: 'Calendar' },
+ clicking: false,
+ hidden: false,
+ durationMs: 750,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Calendar',
+ target: { kind: 'tab', id: 'Calendar' },
+ clicking: true,
+ hidden: false,
+ durationMs: 700,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Calendar',
+ target: { kind: 'rail', id: PEOPLE },
+ clicking: false,
+ hidden: false,
+ durationMs: 700,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: PEOPLE,
+ showRecord: true,
+ recordTab: 'Calendar',
+ target: { kind: 'rail', id: PEOPLE },
+ clicking: true,
+ hidden: false,
+ durationMs: 300,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: PEOPLE,
+ showRecord: false,
+ target: { kind: 'rail', id: PEOPLE },
+ clicking: false,
+ hidden: false,
+ durationMs: 900,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: PEOPLE,
+ showRecord: false,
+ target: { kind: 'rail', id: NOTES },
+ clicking: false,
+ hidden: false,
+ durationMs: 800,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: NOTES,
+ showRecord: false,
+ target: { kind: 'rail', id: NOTES },
+ clicking: true,
+ hidden: false,
+ durationMs: 300,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: NOTES,
+ showRecord: false,
+ target: { kind: 'rail', id: NOTES },
+ clicking: false,
+ hidden: false,
+ durationMs: 600,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: NOTES,
+ showRecord: false,
+ target: { kind: 'rail', id: COMPANIES },
+ clicking: false,
+ hidden: false,
+ durationMs: 1100,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'rail', id: COMPANIES },
+ clicking: true,
+ hidden: false,
+ durationMs: 300,
+ },
+];
+
+// Mobile tour — the three cursors strictly alternate (Alice -> Ben -> Cara) and
+// only touch elements that fit a narrow window: the Companies list, the record
+// Timeline tab, and the sidebar rails. No off-screen record Notes/Calendar tabs.
+const MOBILE_PHASES: TourPhase[] = [
+ // Alice opens the Anthropic record.
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 1400,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'row', id: RECORD_ROW_ID },
+ clicking: false,
+ hidden: false,
+ durationMs: 1000,
+ },
+ {
+ activeCursor: 0,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'row', id: RECORD_ROW_ID },
+ clicking: true,
+ hidden: false,
+ durationMs: 400,
+ },
+ // Ben reads the record, then navigates to People.
+ {
+ activeCursor: 1,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Timeline',
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 1000,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: COMPANIES,
+ showRecord: true,
+ recordTab: 'Timeline',
+ target: { kind: 'rail', id: PEOPLE },
+ clicking: false,
+ hidden: false,
+ durationMs: 900,
+ },
+ {
+ activeCursor: 1,
+ pageItemId: PEOPLE,
+ showRecord: false,
+ target: { kind: 'rail', id: PEOPLE },
+ clicking: true,
+ hidden: false,
+ durationMs: 400,
+ },
+ // Cara browses People, then loops back to Companies.
+ {
+ activeCursor: 2,
+ pageItemId: PEOPLE,
+ showRecord: false,
+ target: { kind: 'home' },
+ clicking: false,
+ hidden: false,
+ durationMs: 1000,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: PEOPLE,
+ showRecord: false,
+ target: { kind: 'rail', id: COMPANIES },
+ clicking: false,
+ hidden: false,
+ durationMs: 900,
+ },
+ {
+ activeCursor: 2,
+ pageItemId: COMPANIES,
+ showRecord: false,
+ target: { kind: 'rail', id: COMPANIES },
+ clicking: true,
+ hidden: false,
+ durationMs: 400,
+ },
+];
+
+export function useProductHeroCursorAutoplay(
+ enabled: boolean,
+ options: { mobile?: boolean } = {},
+): ProductHeroTourState {
+ const { mobile = false } = options;
+ const phases = mobile ? MOBILE_PHASES : DESKTOP_PHASES;
+
+ const [phaseIndex, setPhaseIndex] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ setPhaseIndex(0);
+ return undefined;
+ }
+
+ const timer = setTimeout(() => {
+ setPhaseIndex((current) =>
+ current >= phases.length - 1 ? LOOP_START_INDEX : current + 1,
+ );
+ }, phases[phaseIndex].durationMs);
+
+ return () => clearTimeout(timer);
+ }, [enabled, phaseIndex, phases]);
+
+ const phase = phases[phaseIndex];
+
+ return {
+ activeCursor: phase.activeCursor,
+ clicking: phase.clicking,
+ glideMs: phase.glideMs,
+ hidden: phase.hidden,
+ pageItemId: phase.pageItemId,
+ recordTab: phase.recordTab,
+ showRecord: phase.showRecord,
+ target: phase.target,
+ };
+}
diff --git a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
index 4b05a906b4..83eb29c169 100644
--- a/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
+++ b/packages/twenty-website/src/sections/Hero/components/ProductVisual/use-product-visual-autoplay.ts
@@ -1,177 +1,273 @@
+'use client';
+
import { useEffect, useState } from 'react';
import type { AppPreviewConfig } from '@/sections/AppPreview';
-import { useAppPreviewState } from '@/sections/AppPreview/Shell/use-app-preview-state';
+import { useAppPreviewExperience } from '@/sections/AppPreview/Shell/use-app-preview-experience';
+import type {
+ KanbanPageDefinition,
+ PageDefinition,
+} from '@/sections/AppPreview/types/app-preview-data';
import {
- NEW_COMPANY_ROW,
- NEW_PERSON_ROW,
NEW_TASK_ROWS,
- PROMPT_OPTIONS,
- QONTO_RECORD_PAGE,
+ PRODUCT_VISUAL_SCENES,
+ TASKS_PAGE_ITEM_ID,
+ type ProductVisualSceneDefinition,
} from './product-visual.data';
+import { getVisibleLength } from './streamed-markdown';
type AutoplayOptions = {
externalScene?: number;
+ playbackEnabled?: boolean;
};
+function getStreamProgress(streamedLength: number, fullTextLength: number) {
+ if (fullTextLength === 0) {
+ return 1;
+ }
+
+ return Math.min(streamedLength / fullTextLength, 1);
+}
+
+function clamp(value: number) {
+ return Math.max(0, Math.min(1, value));
+}
+
+function buildFocusedOpportunitiesPage(
+ page: KanbanPageDefinition,
+ streamProgress: number,
+): KanbanPageDefinition {
+ const filteredLanes = page.lanes.filter((lane) => lane.cards.length > 0);
+ const revealProgress = clamp((streamProgress - 0.18) / 0.62);
+ const totalCardCount = filteredLanes.reduce(
+ (total, lane) => total + lane.cards.length,
+ 0,
+ );
+ const visibleCardCount =
+ revealProgress <= 0
+ ? 0
+ : Math.min(totalCardCount, Math.ceil(revealProgress * totalCardCount));
+ let remainingVisibleCards = visibleCardCount;
+
+ const lanes = filteredLanes.map((lane) => {
+ const laneVisibleCount = Math.max(
+ 0,
+ Math.min(lane.cards.length, remainingVisibleCards),
+ );
+
+ remainingVisibleCards -= laneVisibleCount;
+
+ return {
+ ...lane,
+ cards: lane.cards.slice(0, laneVisibleCount),
+ };
+ });
+
+ return {
+ ...page,
+ header: {
+ ...page.header,
+ count: visibleCardCount,
+ title: 'Pipeline by stage',
+ },
+ lanes,
+ };
+}
+
+function resolveDisplayPage(
+ activePage: PageDefinition,
+ activeItemId: string,
+ scene: ProductVisualSceneDefinition,
+ streamProgress: number,
+): PageDefinition {
+ if (scene.kind === 'opportunityReview' && activePage.type === 'kanban') {
+ const generating = streamProgress === 0;
+ const focusedPage = buildFocusedOpportunitiesPage(activePage, 1);
+
+ return {
+ ...focusedPage,
+ generating,
+ header: {
+ ...focusedPage.header,
+ count: generating ? undefined : focusedPage.header.count,
+ },
+ };
+ }
+
+ if (scene.kind === 'dashboardCreation' && activePage.type === 'dashboard') {
+ return {
+ ...activePage,
+ dashboard: {
+ ...activePage.dashboard,
+ generating: streamProgress === 0,
+ },
+ };
+ }
+
+ if (
+ scene.kind === 'taskCreation' &&
+ activeItemId === TASKS_PAGE_ITEM_ID &&
+ activePage.type === 'table'
+ ) {
+ const generating = streamProgress === 0;
+
+ return {
+ ...activePage,
+ generating,
+ header: {
+ ...activePage.header,
+ count: generating ? undefined : NEW_TASK_ROWS.length,
+ },
+ rows: NEW_TASK_ROWS,
+ };
+ }
+
+ if (scene.kind === 'workflowCreation' && activePage.type === 'workflow') {
+ return {
+ ...activePage,
+ generating: streamProgress === 0,
+ };
+ }
+
+ return activePage;
+}
+
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 { externalScene, playbackEnabled = true } = options;
+ const [streamedLength, setStreamedLength] = useState(0);
+ const [completedStepCount, setCompletedStepCount] = useState(0);
+ const selectedOption =
+ externalScene !== undefined
+ ? Math.max(0, Math.min(externalScene, PRODUCT_VISUAL_SCENES.length - 1))
+ : 0;
const {
activeItem,
- activeLabel,
+ activeItemId,
+ activeItemLabel,
activePage,
- handleSelectLabel,
- handleToggleFolder,
+ favorites,
highlightedItemId,
openFolderIds,
revealedObjectIds,
- workspaceNav,
- } = useAppPreviewState(visual);
+ selectPageItem,
+ toggleFolder,
+ workspaceEntries,
+ } = useAppPreviewExperience(visual);
- let displayPage = activePage;
- 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 = {
- ...activePage,
- header: {
- ...activePage.header,
- count: (activePage.header.count ?? 0) + 1,
- },
- rows: [NEW_COMPANY_ROW, ...activePage.rows],
- };
- } else if (personAdded && title === 'All People') {
- displayPage = {
- ...activePage,
- header: {
- ...activePage.header,
- count: (activePage.header.count ?? 0) + 1,
- },
- rows: [NEW_PERSON_ROW, ...activePage.rows],
- };
- } 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;
+ const selectedScene = PRODUCT_VISUAL_SCENES[selectedOption];
+ const fullText = selectedScene.responseText;
+ const fullTextVisibleLength = getVisibleLength(fullText);
+ const streamComplete = streamedLength >= fullTextVisibleLength;
+ const streamProgress = getStreamProgress(
+ streamedLength,
+ fullTextVisibleLength,
+ );
+ const displayPage = resolveDisplayPage(
+ activePage,
+ activeItemId,
+ selectedScene,
+ streamProgress,
+ );
useEffect(() => {
- const option = PROMPT_OPTIONS[selectedOption];
- const fullText = option.response;
+ setStreamedLength(0);
+ setCompletedStepCount(0);
+ selectPageItem(selectedScene.initialPageItemId);
- 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);
- }
+ if (!playbackEnabled) {
+ return undefined;
}
- let index = 0;
- const completedSteps = new Set();
- let companyInjected = false;
- let personInjected = false;
- let tasksInjected = false;
- let recordShown = false;
- setStreamedText('');
- setStreamComplete(false);
- if (!isScrollDriven) {
- setCompanyAdded(false);
- setPersonAdded(false);
- setTasksAdded(false);
- setRecordReady(false);
- }
- const interval = setInterval(() => {
- index += 1;
- setStreamedText(fullText.slice(0, index));
- const progress = index / fullText.length;
- option.navSteps.forEach((step, stepIndex) => {
- if (!completedSteps.has(stepIndex) && progress >= step.at) {
- completedSteps.add(stepIndex);
- handleSelectLabel(step.target);
+ const timers: ReturnType[] = [];
+ let streamInterval: ReturnType | undefined;
+ let cancelled = false;
+
+ const startStreaming = () => {
+ if (cancelled || fullTextVisibleLength === 0) {
+ return;
+ }
+
+ let index = 0;
+ let followUpPageSelected = false;
+
+ streamInterval = setInterval(() => {
+ index = Math.min(index + 1, fullTextVisibleLength);
+ setStreamedLength(index);
+
+ if (
+ !followUpPageSelected &&
+ selectedScene.followUpPageItemId &&
+ getStreamProgress(index, fullTextVisibleLength) >= 0.6
+ ) {
+ followUpPageSelected = true;
+ selectPageItem(selectedScene.followUpPageItemId);
}
- });
- if (selectedOption === 0) {
- if (!companyInjected && progress >= 0.2) {
- companyInjected = true;
- setCompanyAdded(true);
- }
- if (!personInjected && progress >= 0.6) {
- personInjected = true;
- setPersonAdded(true);
+
+ if (index >= fullTextVisibleLength && streamInterval) {
+ clearInterval(streamInterval);
}
+ }, 20);
+ };
+
+ // Agentic preamble: play thinking + tool steps in sequence, then stream the answer.
+ const steps = selectedScene.steps ?? [];
+
+ const playStep = (stepIndex: number) => {
+ if (cancelled) {
+ return;
}
- if (selectedOption === 2 && !tasksInjected && progress >= 0.3) {
- tasksInjected = true;
- setTasksAdded(true);
+
+ if (stepIndex >= steps.length) {
+ startStreaming();
+ return;
}
- if (selectedOption === 3 && !recordShown && progress >= 0.5) {
- recordShown = true;
- setRecordReady(true);
+
+ const timer = setTimeout(() => {
+ setCompletedStepCount(stepIndex + 1);
+ playStep(stepIndex + 1);
+ }, steps[stepIndex].durationMs);
+
+ timers.push(timer);
+ };
+
+ playStep(0);
+
+ return () => {
+ cancelled = true;
+ timers.forEach(clearTimeout);
+
+ if (streamInterval) {
+ clearInterval(streamInterval);
}
- if (index >= fullText.length) {
- clearInterval(interval);
- setStreamComplete(true);
- }
- }, 20);
- return () => clearInterval(interval);
- }, [selectedOption, handleSelectLabel, isScrollDriven]);
+ };
+ }, [fullTextVisibleLength, playbackEnabled, selectPageItem, selectedScene]);
+
+ const agentSteps = selectedScene.steps ?? [];
+ const preambleComplete = completedStepCount >= agentSteps.length;
+ const activeStepIndex = preambleComplete ? -1 : completedStepCount;
return {
activeItem,
- activeLabel,
+ activeItemId,
+ activeItemLabel,
+ activeStepIndex,
+ agentSteps,
+ completedStepCount,
displayPage,
- handleOptionSelect: setSelectedOption,
- handleSelectLabel,
- handleToggleFolder,
+ favorites,
highlightedItemId,
- isScrollDriven,
openFolderIds,
revealedObjectIds,
- selectedOption,
+ selectPageItem,
+ selectedScene,
streamComplete,
- streamedText,
- workspaceNav,
+ streamedTextVisibleLength: streamedLength,
+ toggleFolder,
+ workspaceEntries,
};
}
diff --git a/packages/twenty-website/src/sections/Menu/components/Drawer.tsx b/packages/twenty-website/src/sections/Menu/components/Drawer.tsx
index f0c805422f..b1084d86b3 100644
--- a/packages/twenty-website/src/sections/Menu/components/Drawer.tsx
+++ b/packages/twenty-website/src/sections/Menu/components/Drawer.tsx
@@ -246,7 +246,6 @@ export function MenuDrawer({ navItems, scheme, socialLinks }: MenuDrawerProps) {
.filter((item) => item.showInDrawer)
.map((item, index) => {
const IconComponent = SOCIAL_ICONS[item.icon];
- if (!IconComponent) return null;
return (
diff --git a/packages/twenty-website/src/sections/Menu/components/Menu.tsx b/packages/twenty-website/src/sections/Menu/components/Menu.tsx
index a8ab6215c2..49e5d056a7 100644
--- a/packages/twenty-website/src/sections/Menu/components/Menu.tsx
+++ b/packages/twenty-website/src/sections/Menu/components/Menu.tsx
@@ -9,18 +9,24 @@ import { Social } from './Social';
type MenuProps = {
backgroundColor: string;
+ disableElevation?: boolean;
+ enableBackdropBlur?: boolean;
scheme?: MenuScheme;
socialLinks: MenuSocialLinkType[];
};
export function Menu({
backgroundColor,
+ disableElevation,
+ enableBackdropBlur,
scheme = 'primary',
socialLinks,
}: MenuProps) {
return (
diff --git a/packages/twenty-website/src/sections/Menu/components/Social.tsx b/packages/twenty-website/src/sections/Menu/components/Social.tsx
index fab70b6b06..33ae87a51d 100644
--- a/packages/twenty-website/src/sections/Menu/components/Social.tsx
+++ b/packages/twenty-website/src/sections/Menu/components/Social.tsx
@@ -95,7 +95,6 @@ export function Social({ scheme, socialLinks }: SocialProps) {
.filter((item) => item.showInDesktop)
.map((item, index) => {
const IconComponent = SOCIAL_ICONS[item.icon];
- if (!IconComponent) return null;
return (
diff --git a/packages/twenty-website/src/sections/Menu/types/menu-social-link.ts b/packages/twenty-website/src/sections/Menu/types/menu-social-link.ts
index 206c3543af..de420f9be8 100644
--- a/packages/twenty-website/src/sections/Menu/types/menu-social-link.ts
+++ b/packages/twenty-website/src/sections/Menu/types/menu-social-link.ts
@@ -1,8 +1,10 @@
+import type { SocialIconKey } from '@/icons';
+
export type MenuSocialLinkType = {
ariaLabel: string;
className?: string;
href: string;
- icon: string;
+ icon: SocialIconKey;
label?: string;
showInDesktop: boolean;
showInDrawer: boolean;
diff --git a/packages/twenty-website/src/sections/Tabs/components/TabButton.tsx b/packages/twenty-website/src/sections/Tabs/components/TabButton.tsx
index 2dcbe331d5..5e18b74bca 100644
--- a/packages/twenty-website/src/sections/Tabs/components/TabButton.tsx
+++ b/packages/twenty-website/src/sections/Tabs/components/TabButton.tsx
@@ -7,7 +7,9 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
const Label = styled.span`
+ display: block;
min-width: 0;
+ padding-bottom: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -27,7 +29,7 @@ const StyledButton = styled.button`
line-height: ${theme.lineHeight(3.5)};
max-width: 100%;
min-width: 0;
- padding: ${theme.spacing(2)};
+ padding: calc(${theme.spacing(2)} + 1px) ${theme.spacing(2)};
text-align: left;
transition:
background-color 0.2s ease,
@@ -102,7 +104,7 @@ export function TabButton({
? theme.colors.highlight[100]
: theme.colors.secondary.text[100];
- const Icon = INFORMATIVE_ICONS[tab.icon as keyof typeof INFORMATIVE_ICONS];
+ const Icon = INFORMATIVE_ICONS[tab.icon];
return (
- {Icon ? : null}
+
);
diff --git a/packages/twenty-website/src/sections/Tabs/components/TabButtons.tsx b/packages/twenty-website/src/sections/Tabs/components/TabButtons.tsx
index 7dea65d671..b74d96d7a0 100644
--- a/packages/twenty-website/src/sections/Tabs/components/TabButtons.tsx
+++ b/packages/twenty-website/src/sections/Tabs/components/TabButtons.tsx
@@ -1,5 +1,7 @@
'use client';
+import type { HTMLAttributes, Ref } from 'react';
+
import type { TabType } from '@/sections/Tabs/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -24,19 +26,30 @@ const TabButtonsGrid = styled.div`
type TabButtonsProps = {
activeIndex: number;
+ className?: HTMLAttributes['className'];
+ containerRef?: Ref;
idPrefix: string;
onSelect: (index: number) => void;
+ style?: HTMLAttributes['style'];
tabs: TabType[];
};
export function TabButtons({
activeIndex,
+ className,
+ containerRef,
idPrefix,
onSelect,
+ style,
tabs,
}: TabButtonsProps) {
return (
-
+
{tabs.map((tab, index) => (