Files
twenty/packages/twenty-website/src/sections/AppPreview/Shell/use-app-preview-navigation.ts
T
Abdullah. 4db049db27 [Website] Product hero rework into a single, scroll-driven story. (#21105)
This reworks the /product hero into a single scroll-driven story. It
opens on a collaborative CRM intro — an autoplay cursor ("Alice") tours
real app-preview surfaces (Companies → an Anthropic record, clicking
through its tabs → People → Notes) — then, as you scroll, the light
intro morphs into a dark AI section via one continuous color wipe that
flows up through the (transparent) navigation menu. The AI section
presents four "build" pillars (pipeline board, follow-up tasks, sales
dashboard, email-sequence workflow), each playing an agentic preamble
(thinking + tool steps) and skeleton-loading state before revealing the
finished artifact as a finale. All preview surfaces — table, kanban,
record, dashboard charts, and workflow — are rendered for real and
matched to twenty-front's design (step-8 chart palette, default
opportunity stages, etc.) rather than static images.

Beyond the core morph, this PR layers in the coherence and polish work: 
- the intro halftone reuses the AI section's pattern tinted to the brand
blue and scoped to the visual;
- the workflow finale draws its edges/labels in as a staged build; 
- AI response copy is kept consistent with the visuals (matching stage
names and task counts);
- the ARR line chart fades in cleanly instead of flashing a partial
draw;
- the experience resets sensibly on scroll — the intro cursor tour
restarts when you scroll back to it, and the AI section returns to its
first tab once you're back at the top.

Here is the video showcasing the experience:


https://github.com/user-attachments/assets/c20708ed-4435-4f1a-a1c6-308b99305a97


Awaiting confirmation about the experience from @Bonapara before
merging.
2026-06-08 12:03:30 +00:00

218 lines
5.5 KiB
TypeScript

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<SidebarEntry[]>((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),
};
}