Merge branch 'main' into mcp-streamable-http-405

This commit is contained in:
Sri Hari Haran Sharma
2026-04-09 18:33:10 +05:30
committed by GitHub
39 changed files with 898 additions and 428 deletions
@@ -1,6 +1,14 @@
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { RecordTableWidgetSetReadOnlyColumnHeadersEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetSetReadOnlyColumnHeadersEffect';
import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledTableContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
overflow: hidden;
`;
export const RecordTableWidget = () => {
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
@@ -11,11 +19,13 @@ export const RecordTableWidget = () => {
<RecordTableWidgetSetReadOnlyColumnHeadersEffect
recordTableId={recordIndexId}
/>
<RecordTableWithWrappers
recordTableId={recordIndexId}
objectNameSingular={objectNameSingular}
viewBarId={viewBarInstanceId}
/>
<StyledTableContainer>
<RecordTableWithWrappers
recordTableId={recordIndexId}
objectNameSingular={objectNameSingular}
viewBarId={viewBarInstanceId}
/>
</StyledTableContainer>
</>
);
};
@@ -1,3 +1,4 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -49,8 +50,10 @@ export const useCreatePageLayoutRecordTableWidget = (
const store = useStore();
const createPageLayoutRecordTableWidget =
useCallback((): PageLayoutWidget => {
const createPageLayoutRecordTableWidget = useCallback(
(
objectMetadata?: Pick<EnrichedObjectMetadataItem, 'id' | 'labelPlural'>,
): PageLayoutWidget => {
const allTabLayouts = store.get(pageLayoutCurrentLayoutsState);
const pageLayoutDraggedArea = store.get(pageLayoutDraggedAreaState);
@@ -70,17 +73,18 @@ export const useCreatePageLayoutRecordTableWidget = (
minimumSize,
);
const newWidget = createDefaultRecordTableWidget(
widgetId,
activeTabId,
'Record Table',
{
const newWidget = createDefaultRecordTableWidget({
id: widgetId,
pageLayoutTabId: activeTabId,
title: objectMetadata?.labelPlural ?? 'Record Table',
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
);
objectMetadataId: objectMetadata?.id,
});
const newLayout = {
i: widgetId,
@@ -108,13 +112,15 @@ export const useCreatePageLayoutRecordTableWidget = (
store.set(pageLayoutDraggedAreaState, null);
return newWidget;
}, [
},
[
activeTabId,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
pageLayoutDraggedAreaState,
store,
]);
],
);
return { createPageLayoutRecordTableWidget };
};
@@ -1,9 +1,9 @@
import { createDefaultRecordTableWidget } from '@/page-layout/utils/createDefaultRecordTableWidget';
import {
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
import { createDefaultRecordTableWidget } from '@/page-layout/utils/createDefaultRecordTableWidget';
describe('createDefaultRecordTableWidget', () => {
const widgetId = 'widget-id-1';
@@ -11,12 +11,12 @@ describe('createDefaultRecordTableWidget', () => {
const title = 'My Record Table';
const gridPosition = { row: 1, column: 2, rowSpan: 3, columnSpan: 4 };
const widget = createDefaultRecordTableWidget(
widgetId,
const widget = createDefaultRecordTableWidget({
id: widgetId,
pageLayoutTabId,
title,
gridPosition,
);
});
it('should return correct shape with all required fields', () => {
expect(widget.__typename).toBe('PageLayoutWidget');
@@ -6,12 +6,19 @@ import {
WidgetType,
} from '~/generated-metadata/graphql';
export const createDefaultRecordTableWidget = (
id: string,
pageLayoutTabId: string,
title: string,
gridPosition: GridPosition,
): PageLayoutWidget => {
export const createDefaultRecordTableWidget = ({
id,
pageLayoutTabId,
title,
gridPosition,
objectMetadataId,
}: {
id: string;
pageLayoutTabId: string;
title: string;
gridPosition: GridPosition;
objectMetadataId?: string;
}): PageLayoutWidget => {
return {
__typename: 'PageLayoutWidget',
id,
@@ -30,7 +37,8 @@ export const createDefaultRecordTableWidget = (
rowSpan: gridPosition.rowSpan,
columnSpan: gridPosition.columnSpan,
},
objectMetadataId: null,
objectMetadataId: objectMetadataId ?? null,
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -1,12 +1,15 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { ViewType } from '~/generated-metadata/graphql';
import {
WidgetConfigurationType,
ViewType,
} from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
@@ -14,11 +17,13 @@ const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
export const useCreateViewForRecordTableWidget = (pageLayoutId: string) => {
const { performViewAPICreate } = usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const createViewForRecordTableWidget = useCallback(
async (objectMetadataItem: EnrichedObjectMetadataItem) => {
async (
widgetId: string,
objectMetadataItem: EnrichedObjectMetadataItem,
) => {
const newViewId = v4();
const viewResult = await performViewAPICreate(
@@ -60,8 +65,9 @@ export const useCreateViewForRecordTableWidget = (pageLayoutId: string) => {
try {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
updateCurrentWidgetConfig({
configToUpdate: {
updatePageLayoutWidget(widgetId, {
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: newViewId,
},
});
@@ -72,11 +78,7 @@ export const useCreateViewForRecordTableWidget = (pageLayoutId: string) => {
);
}
},
[
performViewAPICreate,
performViewFieldAPICreate,
updateCurrentWidgetConfig,
],
[performViewAPICreate, performViewFieldAPICreate, updatePageLayoutWidget],
);
return { createViewForRecordTableWidget };
@@ -227,7 +227,7 @@ export const usePageLayoutHeaderInfo = ({
return {
headerIcon: IconTable,
headerIconColor: iconColor,
headerType: t`Record Table`,
headerType: t`View`,
title,
isReadonly: false,
tab: undefined,
@@ -1,5 +1,6 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { FIND_MANY_FRONT_COMPONENTS } from '@/front-components/graphql/queries/findManyFrontComponents';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { useCreatePageLayoutFrontComponentWidget } from '@/page-layout/hooks/useCreatePageLayoutFrontComponentWidget';
import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget';
import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget';
@@ -10,6 +11,7 @@ import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hook
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
@@ -23,7 +25,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { CoreObjectNameSingular, SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconAlignBoxLeftTop,
@@ -86,6 +88,19 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
const { removePageLayoutWidgetAndPreservePosition } =
useRemovePageLayoutWidgetAndPreservePosition(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const firstAvailableObjectMetadataItem =
readableObjectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company,
) ||
[...readableObjectMetadataItems].sort((first, second) =>
first.labelPlural.localeCompare(second.labelPlural),
)[0];
const isRecordTableWidgetEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_TABLE_WIDGET_ENABLED,
);
@@ -179,7 +194,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
closeSidePanelMenu();
};
const handleNavigateToRecordTableSettings = () => {
const handleNavigateToRecordTableSettings = async () => {
if (
isExistingWidgetMissingOrDifferentType(
existingWidget?.type,
@@ -190,9 +205,16 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
removePageLayoutWidgetAndPreservePosition(pageLayoutEditingWidgetId);
}
const newRecordTableWidget = createPageLayoutRecordTableWidget();
const newRecordTableWidget = createPageLayoutRecordTableWidget(
firstAvailableObjectMetadataItem,
);
setPageLayoutEditingWidgetId(newRecordTableWidget.id);
await createViewForRecordTableWidget(
newRecordTableWidget.id,
firstAvailableObjectMetadataItem,
);
}
navigatePageLayoutSidePanel({
@@ -251,7 +273,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
>
<CommandMenuItem
Icon={IconTable}
label={t`Record Table`}
label={t`View`}
id="record-table"
onClick={handleNavigateToRecordTableSettings}
/>
@@ -8,6 +8,7 @@ import { RecordTableDataSourceDropdownContent } from '@/side-panel/pages/page-la
import { RecordTableFieldsDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownContent';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useRecordTableSettingsDescriptions } from '@/side-panel/pages/page-layout/hooks/useRecordTableSettingsDescriptions';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -17,9 +18,10 @@ import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconArrowsSort,
IconDatabase,
IconEye,
IconBox,
IconFilter,
IconListDetails,
IconTable,
} from 'twenty-ui/display';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
@@ -41,21 +43,31 @@ export const SidePanelDashboardRecordTableSettings = () => {
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
if (!isDefined(widgetInEditMode)) {
return null;
}
const { configuration } = widgetInEditMode;
const configuration = widgetInEditMode?.configuration;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
configuration?.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration &&
isDefined(configuration) &&
'viewId' in configuration &&
isDefined(configuration.viewId)
? (configuration.viewId as string)
: undefined;
: null;
const {
sourceDescription,
fieldsDescription,
filterDescription,
sortDescription,
} = useRecordTableSettingsDescriptions({
objectMetadataId: widgetInEditMode?.objectMetadataId,
viewId,
});
if (!isDefined(widgetInEditMode)) {
return null;
}
const hasViewId = isDefined(viewId);
@@ -84,10 +96,23 @@ export const SidePanelDashboardRecordTableSettings = () => {
commandGroups={[]}
selectableItemIds={selectableItemIds}
>
<SidePanelGroup heading={t`Data`}>
<SidePanelGroup heading={t`Settings`}>
<SelectableListItem itemId="object-view-layout">
<CommandMenuItemDropdown
Icon={IconTable}
label={t`Layout`}
id="object-view-layout"
dropdownId="object-view-layout"
dropdownComponents={<></>}
dropdownPlacement="bottom-end"
description={t`Table`}
disabled={true}
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem itemId="record-table-source">
<CommandMenuItemDropdown
Icon={IconDatabase}
Icon={IconBox}
label={t`Source`}
id="record-table-source"
dropdownId="record-table-source"
@@ -98,6 +123,7 @@ export const SidePanelDashboardRecordTableSettings = () => {
}
dropdownPlacement="bottom-end"
hasSubMenu
description={sourceDescription}
contextualTextPosition="right"
/>
</SelectableListItem>
@@ -105,7 +131,7 @@ export const SidePanelDashboardRecordTableSettings = () => {
<>
<SelectableListItem itemId="record-table-fields">
<CommandMenuItemDropdown
Icon={IconEye}
Icon={IconListDetails}
label={t`Fields`}
id="record-table-fields"
dropdownId="record-table-fields"
@@ -117,6 +143,7 @@ export const SidePanelDashboardRecordTableSettings = () => {
}
dropdownPlacement="bottom-end"
hasSubMenu
description={fieldsDescription}
contextualTextPosition="right"
/>
</SelectableListItem>
@@ -130,6 +157,7 @@ export const SidePanelDashboardRecordTableSettings = () => {
Icon={IconFilter}
hasSubMenu
onClick={handleFilterClick}
description={filterDescription}
contextualTextPosition="right"
/>
</SelectableListItem>
@@ -143,6 +171,7 @@ export const SidePanelDashboardRecordTableSettings = () => {
Icon={IconArrowsSort}
hasSubMenu
onClick={handleSortClick}
description={sortDescription}
contextualTextPosition="right"
/>
</SelectableListItem>
@@ -105,14 +105,15 @@ export const RecordTableDataSourceDropdownContent = () => {
(item) => item.id === newObjectMetadataItemId,
);
if (isDefined(selectedObjectMetadataItem)) {
await createViewForRecordTableWidget(selectedObjectMetadataItem);
if (isDefined(selectedObjectMetadataItem) && isDefined(widgetInEditMode)) {
await createViewForRecordTableWidget(
widgetInEditMode.id,
selectedObjectMetadataItem,
);
if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedObjectMetadataItem.labelPlural,
});
}
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedObjectMetadataItem.labelPlural,
});
}
closeDropdown();
@@ -0,0 +1,114 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewById } from '@/views/hooks/useViewById';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
const PLACEHOLDER_INSTANCE_ID =
'record-table-settings-descriptions-placeholder';
type UseRecordTableSettingsDescriptionsParams = {
objectMetadataId: string | null | undefined;
viewId: string | null;
};
export const useRecordTableSettingsDescriptions = ({
objectMetadataId,
viewId,
}: UseRecordTableSettingsDescriptionsParams) => {
const { objectMetadataItems } = useObjectMetadataItems();
const { view } = useViewById(viewId);
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataId,
);
const recordIndexId =
isDefined(viewId) && isDefined(sourceObjectMetadataItem)
? getRecordIndexIdFromObjectNamePluralAndViewId(
sourceObjectMetadataItem.namePlural,
viewId,
)
: PLACEHOLDER_INSTANCE_ID;
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
recordIndexId,
);
const currentRecordSorts = useAtomComponentStateValue(
currentRecordSortsComponentState,
recordIndexId,
);
const currentRecordFields = useAtomComponentStateValue(
currentRecordFieldsComponentState,
recordIndexId,
);
const hasViewId = isDefined(viewId);
const activeVisibleFieldLabels: Array<string | undefined> = hasViewId
? currentRecordFields
.filter((field) => field.isVisible)
.map(
(field) =>
sourceObjectMetadataItem?.fields.find(
(metaField) => metaField.id === field.fieldMetadataItemId,
)?.label,
)
: [];
const activeFilterLabels: Array<string | undefined> =
currentRecordFilters.length > 0
? currentRecordFilters.map((filter) => filter.label)
: (view?.viewFilters ?? []).map(
(filter) =>
sourceObjectMetadataItem?.fields.find(
(field) => field.id === filter.fieldMetadataId,
)?.label,
);
const activeSortLabels: Array<string | undefined> = (
currentRecordSorts.length > 0 ? currentRecordSorts : (view?.viewSorts ?? [])
).map(
(sort) =>
sourceObjectMetadataItem?.fields.find(
(field) => field.id === sort.fieldMetadataId,
)?.label,
);
const sourceDescription = sourceObjectMetadataItem?.labelPlural;
const fieldsDescription =
activeVisibleFieldLabels.length === 1
? activeVisibleFieldLabels[0]
: activeVisibleFieldLabels.length > 1
? t`${activeVisibleFieldLabels.length} shown`
: undefined;
const filterDescription =
activeFilterLabels.length === 1
? activeFilterLabels[0]
: activeFilterLabels.length > 1
? t`${activeFilterLabels.length} shown`
: undefined;
const sortDescription =
activeSortLabels.length === 1
? activeSortLabels[0]
: activeSortLabels.length > 1
? t`${activeSortLabels.length} shown`
: undefined;
return {
sourceDescription,
fieldsDescription,
filterDescription,
sortDescription,
};
};
@@ -135,12 +135,13 @@ export const MenuItem = ({
<RightIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
{RightComponent}
{hasSubMenu && !disabled && (
{hasSubMenu && (
<StyledSubMenuIcon
animate={{ rotate: isSubMenuOpened ? 90 : 0 }}
transition={{
duration: theme.animation.duration.normal,
}}
style={{ visibility: disabled ? 'hidden' : 'visible' }}
>
<IconChevronRight
size={theme.icon.size.sm}
File diff suppressed because one or more lines are too long
@@ -152,10 +152,63 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
},
type: 'row',
},
{
featureLabel: 'Custom fields',
tiers: {
organization: { kind: 'text', text: 'Unlimited' },
pro: { kind: 'text', text: 'Unlimited' },
},
type: 'row',
},
{
featureLabel: 'API access',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'yes', label: 'Yes' },
},
type: 'row',
},
{
featureLabel: 'Webhooks',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'yes', label: 'Yes' },
},
type: 'row',
},
{
title: 'Security',
type: 'category',
},
{
featureLabel: 'SSO / SAML',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
{
featureLabel: 'Audit logs',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
{
featureLabel: 'Role-based access control',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
],
initialVisibleRowCount: 13,
seeMoreFeaturesCta: {
href: '/pricing',
label: 'See more features',
collapseLabel: 'Show less',
expandLabel: 'See more features',
},
tierColumns: [
{ id: 'pro', label: 'Pro' },
@@ -32,7 +32,7 @@ export default async function PricingPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.secondary.background[5]}
backgroundColor="#F3F3F3"
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
@@ -209,7 +209,6 @@ export function WhyTwenty() {
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
canvas.style.cursor = 'crosshair';
container.appendChild(canvas);
const clock = new THREE.Clock();
@@ -235,7 +235,6 @@ export function Logo() {
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'crosshair';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
@@ -3,15 +3,13 @@
import { Eyebrow, Heading } from '@/design-system/components';
import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
import { CARD_WIDTH_DESKTOP } from '@/sections/Helped/utils/helped-scene-layout';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { Card } from '../Card/Card';
const SCROLL_HEIGHT_VH = 420;
const ScrollStage = styled.section`
height: 280vh;
position: relative;
width: 100%;
`;
@@ -54,8 +52,7 @@ const CardsLayer = styled.div`
const CardPositioner = styled.div`
position: absolute;
width: min(${CARD_WIDTH_DESKTOP}px, calc(100% - ${theme.spacing(8)}));
will-change: transform, opacity;
will-change: top, opacity;
`;
type SceneProps = {
@@ -65,24 +62,18 @@ type SceneProps = {
export function Scene({ data }: SceneProps) {
const sectionRef = useRef<HTMLElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const headlineRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<ScrollStage
aria-label="Customer stories"
ref={sectionRef}
style={{ height: `${SCROLL_HEIGHT_VH}vh` }}
>
<ScrollStage aria-label="Customer stories" ref={sectionRef}>
<HelpedSceneScrollLayoutEffect
cardRefs={cardRefs}
cards={data.cards}
headlineRef={headlineRef}
innerRef={innerRef}
sectionRef={sectionRef}
/>
<StickyInner ref={innerRef}>
<HeadlineBlock ref={headlineRef}>
<HeadlineBlock>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
<Heading as="h2" segments={data.heading} size="xl" weight="light" />
</HeadlineBlock>
@@ -15,14 +15,12 @@ type HelpedSceneScrollLayoutEffectProps = HelpedSceneLayoutRefs & {
export function HelpedSceneScrollLayoutEffect({
cardRefs,
cards,
headlineRef,
innerRef,
sectionRef,
}: HelpedSceneScrollLayoutEffectProps) {
useEffect(() => {
const refs: HelpedSceneLayoutRefs = {
cardRefs,
headlineRef,
innerRef,
sectionRef,
};
@@ -51,7 +49,7 @@ export function HelpedSceneScrollLayoutEffect({
window.cancelAnimationFrame(rafId);
}
};
}, [cardRefs, cards, headlineRef, innerRef, sectionRef]);
}, [cardRefs, cards, innerRef, sectionRef]);
return null;
}
@@ -3,60 +3,36 @@ import type { RefObject } from 'react';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
import { theme } from '@/theme';
export const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE_MAX = 360;
const HORIZONTAL_PAD = 32;
// Scroll progress 0..1 through the tall section
const HEADLINE_FADE_START = 0.76;
const HEADLINE_FADE_END = 0.92;
const HEADLINE_LIFT_PX = 48;
// Each card fades/slides in over the same window, shifted along the scroll
const CARD_WINDOW = 0.2;
const CARD_STAGGER = 0.12;
const CARD_FIRST_START = 0.06;
const ENTER_OFFSET_Y_RATIO = 0.34;
const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE = 360;
const PROGRESS_SCALE = 1.25;
const FADE_FRACTION = 0.15;
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function cardRestPosition(
// Right, left, center for cards 0, 1, 2+
function cardLeft(
index: number,
innerWidth: number,
innerHeight: number,
cardWidth: number,
isDesktop: boolean,
): { left: number; top: number } {
if (isDesktop) {
if (index === 0) {
return {
left: innerWidth - cardWidth - innerWidth * 0.035,
top: innerHeight * 0.08,
};
}
if (index === 1) {
return {
left: innerWidth * 0.03,
top: innerHeight * 0.3,
};
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.52,
};
): number {
if (!isDesktop) {
return (innerWidth - cardWidth) / 2;
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.36 + index * 0.06 * innerHeight,
};
if (index === 0) {
return innerWidth - cardWidth - innerWidth * 0.035;
}
if (index === 1) {
return innerWidth * 0.03;
}
return (innerWidth - cardWidth) / 2;
}
export type HelpedSceneLayoutRefs = {
cardRefs: RefObject<(HTMLDivElement | null)[]>;
headlineRef: RefObject<HTMLDivElement | null>;
innerRef: RefObject<HTMLDivElement | null>;
sectionRef: RefObject<HTMLElement | null>;
};
@@ -65,9 +41,8 @@ export function applyHelpedSceneLayout(
refs: HelpedSceneLayoutRefs,
cards: readonly HeadingCardType[],
): void {
const { sectionRef, innerRef, headlineRef, cardRefs } = refs;
const section = sectionRef.current;
const inner = innerRef.current;
const section = refs.sectionRef.current;
const inner = refs.innerRef.current;
if (!section || !inner) {
return;
}
@@ -79,61 +54,43 @@ export function applyHelpedSceneLayout(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
const rect = section.getBoundingClientRect();
const scrollRange = Math.max(1, section.offsetHeight - window.innerHeight);
const progress = clamp01(-rect.top / scrollRange);
const progress =
clamp01(-section.getBoundingClientRect().top / scrollRange) * PROGRESS_SCALE;
const innerWidth = inner.offsetWidth;
const innerHeight = inner.offsetHeight;
const cardWidth = Math.min(
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE_MAX,
innerWidth - HORIZONTAL_PAD * 2,
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE,
innerWidth - 64,
);
if (headlineRef.current) {
const headlineT = clamp01(
(progress - HEADLINE_FADE_START) /
(HEADLINE_FADE_END - HEADLINE_FADE_START),
);
headlineRef.current.style.opacity = String(1 - headlineT);
headlineRef.current.style.transform = `translate3d(0, ${-headlineT * HEADLINE_LIFT_PX}px, 0)`;
}
const enterOffsetY = innerHeight * ENTER_OFFSET_Y_RATIO;
cards.forEach((_, index) => {
const node = cardRefs.current[index];
const node = refs.cardRefs.current[index];
if (!node) {
return;
}
const { left: restLeft, top: restTop } = cardRestPosition(
index,
innerWidth,
innerHeight,
cardWidth,
isDesktop,
);
node.style.width = `${cardWidth}px`;
node.style.zIndex = String(10 + index);
node.style.left = `${cardLeft(index, innerWidth, cardWidth, isDesktop)}px`;
if (reducedMotion) {
node.style.opacity = '1';
node.style.transform = 'none';
node.style.left = `${restLeft}px`;
node.style.top = `${restTop}px`;
node.style.top = `${innerHeight * (0.15 + index * 0.25)}px`;
return;
}
const windowStart = CARD_FIRST_START + index * CARD_STAGGER;
const windowEnd = windowStart + CARD_WINDOW;
const cardT = clamp01(
(progress - windowStart) / (windowEnd - windowStart),
);
// Card 1: 0.050.55, Card 2: 0.350.85, Card 3: 0.651.15
const cardStart = 0.05 + index * 0.3;
const travel = clamp01((progress - cardStart) / 0.5);
node.style.left = `${restLeft}px`;
node.style.top = `${restTop + enterOffsetY * (1 - cardT)}px`;
node.style.opacity = String(cardT);
node.style.top = `${innerHeight * (1.1 - travel * 1.4)}px`;
node.style.opacity = String(
Math.min(
clamp01(travel / FADE_FRACTION),
clamp01((1 - travel) / FADE_FRACTION),
),
);
});
}
@@ -33,7 +33,7 @@ export function Flow({ scrollContainerRef, steps }: FlowProps) {
steps={steps}
/>
<RightColumn>
<Visual />
<Visual scrollProgress={scrollProgress} />
</RightColumn>
</>
);
@@ -1,30 +1,61 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import { HOME_STEPPER_HOLD_FRACTIONS } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { CSSProperties } from 'react';
import type { HomeStepperStepType } from '../../types/HomeStepperStep';
import { ProgressBar } from '../ProgressBar/ProgressBar';
const LeftColumnRoot = styled.div`
display: grid;
grid-template-columns: auto 1fr;
gap: ${theme.spacing(6)};
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(20)};
display: grid;
margin-left: calc(-1 * ${theme.spacing(4)});
}
`;
const StepsColumn = styled.div`
const StickyPanel = styled.div`
display: grid;
gap: ${theme.spacing(6)};
grid-template-columns: auto 1fr;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
align-self: start;
gap: ${theme.spacing(20)};
grid-area: 1 / 1;
height: 100vh;
overflow: hidden;
position: sticky;
top: 0;
}
`;
const SpacerStack = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
grid-area: 1 / 1;
}
`;
const StepSpacer = styled.div`
min-height: 100vh;
`;
const StepsContainer = styled.div`
display: grid;
grid-template-columns: 1fr;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 454px;
& > * {
grid-area: 1 / 1;
}
}
`;
@@ -32,18 +63,28 @@ const StepBlock = styled.div`
align-content: center;
display: grid;
grid-template-columns: 1fr;
max-width: 454px;
min-height: 80vh;
min-width: 0;
opacity: 0.3;
row-gap: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 100vh;
row-gap: ${theme.spacing(6)};
}
transition: opacity 0.3s ease;
&[data-active='true'] {
opacity: 1;
}
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 0;
row-gap: ${theme.spacing(6)};
transition: none;
&,
&[data-active='true'] {
opacity: var(--step-opacity, 0);
transform: var(--step-transform, translateY(40vh));
}
}
`;
export type HomeStepperLeftColumnProps = {
@@ -52,6 +93,36 @@ export type HomeStepperLeftColumnProps = {
steps: HomeStepperStepType[];
};
function computeDesktopStepStyle(
index: number,
activeStepIndex: number,
localProgress: number,
stepCount: number,
): { opacity: number; transform: string } {
const holdMax = HOME_STEPPER_HOLD_FRACTIONS[activeStepIndex] ?? 1;
if (index === activeStepIndex) {
if (index === stepCount - 1 || localProgress <= holdMax) {
return { opacity: 1, transform: 'translateY(0)' };
}
const t = Math.min(1, (localProgress - holdMax) / (1 - holdMax));
return {
opacity: 1 - t,
transform: `translateY(${-40 * t}vh)`,
};
}
if (index === activeStepIndex + 1 && localProgress > holdMax) {
const t = Math.min(1, (localProgress - holdMax) / (1 - holdMax));
return {
opacity: t,
transform: `translateY(${40 * (1 - t)}vh)`,
};
}
return { opacity: 0, transform: 'translateY(40vh)' };
}
export function LeftColumn({
activeStepIndex,
localProgress,
@@ -59,22 +130,44 @@ export function LeftColumn({
}: HomeStepperLeftColumnProps) {
return (
<LeftColumnRoot>
<ProgressBar
activeStepIndex={activeStepIndex}
localProgress={localProgress}
steps={steps}
/>
<StepsColumn>
{steps.map((step, index) => (
<StepBlock
data-active={String(index === activeStepIndex)}
key={index}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
<StickyPanel>
<ProgressBar
activeStepIndex={activeStepIndex}
localProgress={localProgress}
steps={steps}
/>
<StepsContainer>
{steps.map((step, index) => {
const { opacity, transform } = computeDesktopStepStyle(
index,
activeStepIndex,
localProgress,
steps.length,
);
return (
<StepBlock
data-active={String(index === activeStepIndex)}
key={index}
style={
{
'--step-opacity': opacity,
'--step-transform': transform,
} as CSSProperties
}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
);
})}
</StepsContainer>
</StickyPanel>
<SpacerStack>
{steps.map((_, index) => (
<StepSpacer key={index} />
))}
</StepsColumn>
</SpacerStack>
</LeftColumnRoot>
);
}
@@ -7,9 +7,10 @@ const StyledRightColumn = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
@@ -1,9 +1,10 @@
'use client';
import { scrollProgressToHomeStepperLottieFrame } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Lottie from 'lottie-react';
import { useEffect, useState } from 'react';
import Lottie, { type LottieRefCurrentProps } from 'lottie-react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const HOME_STEPPER_LOTTIE_SRC = '/images/home/stepper/lottie.json';
@@ -16,17 +17,51 @@ const LottieSlot = styled.div`
width: 100%;
`;
type LottieAnimationData = Record<string, unknown>;
type LottieAnimationData = {
ip?: number;
op?: number;
} & Record<string, unknown>;
export function StepperLottie() {
const [animationData, setAnimationData] = useState<LottieAnimationData | null>(
null,
type StepperLottieProps = {
scrollProgress: number;
};
function lottieTotalFrameCount(animationData: LottieAnimationData): number {
const op = typeof animationData.op === 'number' ? animationData.op : 0;
const ip = typeof animationData.ip === 'number' ? animationData.ip : 0;
const rounded = Math.round(op - ip);
return rounded >= 1 ? rounded : 0;
}
function applyScrollToLottie(
lottieApi: LottieRefCurrentProps | null,
scrollProgress: number,
totalFrames: number,
) {
if (!lottieApi?.animationLoaded || totalFrames <= 0) {
return;
}
const frame = scrollProgressToHomeStepperLottieFrame(
scrollProgress,
totalFrames,
);
lottieApi.goToAndStop(frame, true);
}
export function StepperLottie({ scrollProgress }: StepperLottieProps) {
const [animationData, setAnimationData] =
useState<LottieAnimationData | null>(null);
const [totalFrames, setTotalFrames] = useState(0);
const lottieRef = useRef<LottieRefCurrentProps | null>(null);
const scrollProgressRef = useRef(scrollProgress);
scrollProgressRef.current = scrollProgress;
const totalFramesRef = useRef(totalFrames);
totalFramesRef.current = totalFrames;
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
fetch(HOME_STEPPER_LOTTIE_SRC)
fetch(HOME_STEPPER_LOTTIE_SRC, { signal: controller.signal })
.then((response) => {
if (!response.ok) {
throw new Error(`Lottie fetch failed: ${response.status}`);
@@ -34,21 +69,28 @@ export function StepperLottie() {
return response.json() as Promise<LottieAnimationData>;
})
.then((data) => {
if (!cancelled) {
setAnimationData(data);
}
setAnimationData(data);
setTotalFrames(lottieTotalFrameCount(data));
})
.catch(() => {
if (!cancelled) {
setAnimationData(null);
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
setAnimationData(null);
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
useLayoutEffect(() => {
if (!animationData) {
return;
}
applyScrollToLottie(lottieRef.current, scrollProgress, totalFrames);
}, [animationData, scrollProgress, totalFrames]);
if (!animationData) {
return <LottieSlot aria-hidden />;
}
@@ -57,8 +99,18 @@ export function StepperLottie() {
<LottieSlot aria-hidden>
<Lottie
animationData={animationData}
loop
autoplay={false}
loop={false}
lottieRef={lottieRef}
style={{ height: '100%', maxWidth: '100%', width: '100%' }}
onDOMLoaded={() => {
lottieRef.current?.setSubframe(true);
applyScrollToLottie(
lottieRef.current,
scrollProgressRef.current,
totalFramesRef.current,
);
}}
/>
</LottieSlot>
);
@@ -6,13 +6,17 @@ import { StepperLottie } from './StepperLottie';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/background.png';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.png';
export function Visual() {
type VisualProps = {
scrollProgress: number;
};
export function Visual({ scrollProgress }: VisualProps) {
return (
<StepperVisualFrame
backgroundSrc={HOME_STEPPER_BACKGROUND}
shapeSrc={HOME_STEPPER_SHAPE}
>
<StepperLottie />
<StepperLottie scrollProgress={scrollProgress} />
</StepperVisualFrame>
);
}
@@ -0,0 +1,41 @@
const STEP_COUNT = 3;
function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value));
}
// Lottie timeline boundaries (from the animation source):
// Step 1 content: frames 0345, transition 1→2: frames 346415
// Step 2 content: frames 416933, transition 2→3: frames 934980
// Step 3 content: frames 981end
const STEP_1_TRANSITION_END = 415;
const STEP_2_TRANSITION_END = 980;
// Per-step hold fraction: how much of each step's scroll drives Lottie-only
// (left column stays fixed); the remainder drives the content transition.
export const HOME_STEPPER_HOLD_FRACTIONS = [
345 / STEP_1_TRANSITION_END,
(933 - STEP_1_TRANSITION_END) / (STEP_2_TRANSITION_END - STEP_1_TRANSITION_END),
1,
] as const;
export function scrollProgressToHomeStepperLottieFrame(
scrollProgress: number,
totalFrames: number,
): number {
const lastIndex = Math.max(0, totalFrames - 1);
const clamped = clampUnit(scrollProgress);
const stepFloat = clamped * STEP_COUNT;
const stepIndex = Math.min(STEP_COUNT - 1, Math.floor(stepFloat));
const localProgress = stepFloat - stepIndex;
const rangeStart =
stepIndex === 0 ? 0 : stepIndex === 1 ? STEP_1_TRANSITION_END : STEP_2_TRANSITION_END;
const rangeEnd =
stepIndex === 0 ? STEP_1_TRANSITION_END : stepIndex === 1 ? STEP_2_TRANSITION_END : lastIndex;
const frame = rangeStart + localProgress * (rangeEnd - rangeStart);
return Math.max(0, Math.min(lastIndex, frame));
}
@@ -16,6 +16,7 @@ import { MenuDrawer } from '../Drawer/Drawer';
const StyledSection = styled.section`
backdrop-filter: blur(10px);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
min-width: 0;
position: sticky;
top: 0;
@@ -24,7 +25,8 @@ const StyledSection = styled.section`
`;
const StyledContainer = styled(Container)`
padding-top: ${theme.spacing(4)};
padding-top: ${theme.spacing(2)};
padding-bottom: ${theme.spacing(2)};
position: relative;
z-index: 100;
`;
@@ -1,4 +1,9 @@
import { LinkButton } from '@/design-system/components';
'use client';
import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { CheckIcon } from '@/icons';
import type {
PlanTableCellType,
@@ -8,6 +13,7 @@ import type {
} from '@/sections/PlanTable/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { CalculatorEmbed } from '../CalculatorEmbed/CalculatorEmbed';
const TableScope = styled.div`
@@ -15,7 +21,6 @@ const TableScope = styled.div`
color: ${theme.colors.secondary.text[100]};
display: flex;
flex-direction: column;
gap: ${theme.spacing(10)};
width: 100%;
`;
@@ -94,12 +99,36 @@ const CategoryTitle = styled.span`
width: 100%;
`;
const CollapsibleWrapper = styled.div`
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 0.4s ease,
opacity 0.4s ease;
width: 100%;
&[data-expanded='true'] {
grid-template-rows: 1fr;
opacity: 1;
}
`;
const CollapsibleInner = styled.div`
overflow: hidden;
`;
const CtaRow = styled.div`
display: flex;
justify-content: center;
margin-top: ${theme.spacing(10)};
width: 100%;
`;
const ToggleButton = styled.button`
${buttonBaseStyles}
`;
type CellValueProps = {
cell: PlanTableCellType;
};
@@ -162,6 +191,44 @@ type ContentProps = {
};
export function Content({ data }: ContentProps) {
const [expanded, setExpanded] = useState(false);
const initialRows = data.rows.slice(0, data.initialVisibleRowCount);
const extraRows = data.rows.slice(data.initialVisibleRowCount);
const hasMoreRows = extraRows.length > 0;
const toggleLabel = expanded
? data.seeMoreFeaturesCta.collapseLabel
: data.seeMoreFeaturesCta.expandLabel;
const mapRows = (rows: PlanTableDataType['rows'], startIndex: number) =>
rows.map((row, index) => {
const rowIndex = startIndex + index;
if (row.type === 'category') {
return (
<CategoryRow key={`${row.title}-${rowIndex}`} title={row.title} />
);
}
if (row.type === 'calculator') {
return (
<CalculatorEmbed
calculator={row.calculator}
key={`calculator-${rowIndex}`}
/>
);
}
return (
<FeatureRow
key={`${row.featureLabel}-${rowIndex}`}
row={row}
tierColumns={data.tierColumns}
/>
);
});
return (
<TableScope>
<GridRow>
@@ -171,43 +238,27 @@ export function Content({ data }: ContentProps) {
))}
</GridRow>
{data.rows.map((row, rowIndex) => {
if (row.type === 'category') {
return (
<CategoryRow
key={`${row.title}-${rowIndex}`}
title={row.title}
{mapRows(initialRows, 0)}
{hasMoreRows && (
<CollapsibleWrapper data-expanded={String(expanded)}>
<CollapsibleInner>
{mapRows(extraRows, data.initialVisibleRowCount)}
</CollapsibleInner>
</CollapsibleWrapper>
)}
{hasMoreRows && (
<CtaRow>
<ToggleButton onClick={() => setExpanded((prev) => !prev)}>
<BaseButton
color="primary"
label={toggleLabel}
variant="outlined"
/>
);
}
if (row.type === 'calculator') {
return (
<CalculatorEmbed
calculator={row.calculator}
key={`calculator-${rowIndex}`}
/>
);
}
return (
<FeatureRow
key={`${row.featureLabel}-${rowIndex}`}
row={row}
tierColumns={data.tierColumns}
/>
);
})}
<CtaRow>
<LinkButton
color="secondary"
href={data.seeMoreFeaturesCta.href}
label={data.seeMoreFeaturesCta.label}
type="link"
variant="outlined"
/>
</CtaRow>
</ToggleButton>
</CtaRow>
)}
</TableScope>
);
}
@@ -69,10 +69,11 @@ export type PlanTableBodyRowDataType =
export type PlanTableDataType = {
featureColumnLabel: string;
initialVisibleRowCount: number;
rows: PlanTableBodyRowDataType[];
seeMoreFeaturesCta: {
href: string;
label: string;
collapseLabel: string;
expandLabel: string;
};
tierColumns: PlanTableTierColumnType[];
};
@@ -21,6 +21,10 @@ const ContentRoot = styled.div`
align-self: stretch;
gap: ${theme.spacing(20)};
margin-left: calc(-1 * ${theme.spacing(4)});
position: sticky;
top: 0;
height: 100vh;
align-items: center;
}
`;
@@ -33,8 +37,6 @@ const StepsColumn = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
height: max-content;
max-width: 556px;
position: sticky;
top: ${theme.spacing(20)};
}
`;
@@ -13,9 +13,10 @@ const VisualColumn = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
@@ -22,6 +22,10 @@ const CopyColumn = styled.div`
gap: ${theme.spacing(2)};
max-width: 400px;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: center;
}
`;
const RightColumn = styled.div`
@@ -7,7 +7,7 @@ import type {
} from '@/sections/Salesforce/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
import { useRef } from 'react';
const formatPriceAmount = (amount: number) =>
`$${new Intl.NumberFormat('en-US').format(amount)}`;
@@ -54,10 +54,6 @@ const calculatePriceAmounts = (
};
const PANEL_BACKGROUND = '#c9c9c9';
const CARD_STICKY_TOP_OFFSET_PX = 64;
const CARD_STICKY_BOTTOM_OFFSET_PX = 340;
type StickyHeaderMode = 'absolute' | 'fixed';
const Panel = styled.div`
background-color: ${PANEL_BACKGROUND};
@@ -69,17 +65,7 @@ const Panel = styled.div`
width: 100%;
`;
const StickyHeaderSpacer = styled.div<{ $height: number }>`
height: ${({ $height }) => $height}px;
width: 100%;
`;
const StickyHeader = styled.div<{
$absoluteTop: number;
$left: number;
$mode: StickyHeaderMode;
$width: number;
}>`
const PricingHeader = styled.div`
background-color: ${PANEL_BACKGROUND};
box-shadow:
inset 1px 0 0 0 #dfdfdf,
@@ -88,11 +74,8 @@ const StickyHeader = styled.div<{
inset -2px 0 0 0 #808080,
inset 0 1px 0 0 #dfdfdf,
inset 0 2px 0 0 #ffffff;
left: ${({ $left, $mode }) => ($mode === 'fixed' ? `${$left}px` : '0')};
position: ${({ $mode }) => ($mode === 'fixed' ? 'fixed' : 'absolute')};
top: ${({ $absoluteTop, $mode }) =>
$mode === 'fixed' ? `${CARD_STICKY_TOP_OFFSET_PX}px` : `${$absoluteTop}px`};
width: ${({ $mode, $width }) => ($mode === 'fixed' ? `${$width}px` : '100%')};
position: relative;
width: 100%;
z-index: 20;
`;
@@ -437,139 +420,20 @@ export type PricingWindowProps = {
pricing: SalesforcePricingPanelType;
};
type StickyHeaderState = {
absoluteTop: number;
height: number;
left: number;
mode: StickyHeaderMode;
width: number;
};
export function PricingWindow({
checkedIds,
onAddonToggle,
onClose,
pricing,
}: PricingWindowProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const addonAnchorRefs = useRef<Record<string, HTMLLabelElement | null>>({});
const [stickyHeaderState, setStickyHeaderState] = useState<StickyHeaderState>(
{
absoluteTop: 0,
height: 0,
left: 0,
mode: 'absolute',
width: 0,
},
);
const { fixedPriceAmount, perSeatPriceAmount, totalPriceAmount } =
calculatePriceAmounts(pricing, checkedIds);
useEffect(() => {
let frameId = 0;
const updateStickyHeaderState = () => {
frameId = 0;
const panel = panelRef.current;
const stickyHeader = stickyHeaderRef.current;
if (!panel || !stickyHeader) {
return;
}
const panelRect = panel.getBoundingClientRect();
const stickyHeight = stickyHeader.offsetHeight;
const panelHeight = panel.offsetHeight;
const panelTop = window.scrollY + panelRect.top;
const maxAbsoluteTop = Math.max(
0,
panelHeight - stickyHeight - CARD_STICKY_BOTTOM_OFFSET_PX,
);
const fixedEndScrollY =
panelTop +
panelHeight -
stickyHeight -
CARD_STICKY_TOP_OFFSET_PX -
CARD_STICKY_BOTTOM_OFFSET_PX;
const nextState: StickyHeaderState =
window.scrollY >= panelTop - CARD_STICKY_TOP_OFFSET_PX &&
window.scrollY < fixedEndScrollY
? {
absoluteTop: 0,
height: stickyHeight,
left: panelRect.left,
mode: 'fixed',
width: panelRect.width,
}
: {
absoluteTop:
window.scrollY >= fixedEndScrollY ? maxAbsoluteTop : 0,
height: stickyHeight,
left: 0,
mode: 'absolute',
width: panelRect.width,
};
setStickyHeaderState((previous) =>
previous.absoluteTop === nextState.absoluteTop &&
previous.height === nextState.height &&
previous.left === nextState.left &&
previous.mode === nextState.mode &&
previous.width === nextState.width
? previous
: nextState,
);
};
const requestStickyHeaderUpdate = () => {
if (frameId !== 0) {
return;
}
frameId = window.requestAnimationFrame(updateStickyHeaderState);
};
requestStickyHeaderUpdate();
window.addEventListener('resize', requestStickyHeaderUpdate);
window.addEventListener('scroll', requestStickyHeaderUpdate, {
passive: true,
});
const resizeObserver = new ResizeObserver(requestStickyHeaderUpdate);
if (panelRef.current) {
resizeObserver.observe(panelRef.current);
}
if (stickyHeaderRef.current) {
resizeObserver.observe(stickyHeaderRef.current);
}
return () => {
if (frameId !== 0) {
window.cancelAnimationFrame(frameId);
}
resizeObserver.disconnect();
window.removeEventListener('resize', requestStickyHeaderUpdate);
window.removeEventListener('scroll', requestStickyHeaderUpdate);
};
}, [fixedPriceAmount]);
return (
<Panel ref={panelRef}>
<Panel>
<WindowChrome aria-hidden="true" />
<StickyHeaderSpacer $height={stickyHeaderState.height} />
<StickyHeader
$absoluteTop={stickyHeaderState.absoluteTop}
$left={stickyHeaderState.left}
$mode={stickyHeaderState.mode}
$width={stickyHeaderState.width}
ref={stickyHeaderRef}
>
<PricingHeader>
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
<TitleBarActions>
@@ -621,7 +485,7 @@ export function PricingWindow({
<Separator aria-hidden="true" />
</SummaryInner>
</SummaryPad>
</StickyHeader>
</PricingHeader>
<ContentPad>
<Inner>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
@@ -1,6 +1,10 @@
'use client';
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
import { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { FeatureCard } from '../FeatureCard/FeatureCard';
const FeatureCardsGrid = styled.div`
@@ -15,13 +19,32 @@ const FeatureCardsGrid = styled.div`
}
`;
const CardSlot = styled.div`
will-change: transform, opacity;
`;
type FeatureCardsProps = { featureCards: ThreeCardsFeatureCardType[] };
export function FeatureCards({ featureCards }: FeatureCardsProps) {
const gridRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<FeatureCardsGrid>
<FeatureCardsGrid ref={gridRef}>
<ThreeCardsScrollLayoutEffect
cardCount={featureCards.length}
cardRefs={cardRefs}
gridRef={gridRef}
/>
{featureCards.map((featureCard, index) => (
<FeatureCard key={index} featureCard={featureCard} />
<CardSlot
key={index}
ref={(element) => {
cardRefs.current[index] = element;
}}
>
<FeatureCard featureCard={featureCard} />
</CardSlot>
))}
</FeatureCardsGrid>
);
@@ -1,7 +1,6 @@
'use client';
import { Body, Heading, IconButton } from '@/design-system/components';
import { ArrowRightIcon } from '@/icons';
import { Body, Heading } from '@/design-system/components';
import { THREE_CARDS_ILLUSTRATIONS } from '@/illustrations';
import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
@@ -40,10 +39,9 @@ const CardEmbed = styled.div`
`;
const CardFooter = styled.footer`
display: grid;
grid-template-columns: auto auto auto 1fr;
align-items: start;
column-gap: ${theme.spacing(2)};
display: flex;
align-items: center;
gap: ${theme.spacing(2)};
`;
const AttributionPipe = styled.span`
@@ -53,10 +51,6 @@ const AttributionPipe = styled.span`
border-left: 1px solid ${theme.colors.primary.border[20]};
`;
const FooterTrailingAction = styled.div`
justify-self: end;
`;
const CardBodyCell = styled.div`
align-self: start;
min-height: 0;
@@ -111,17 +105,6 @@ export function IllustrationCard({
size="xs"
weight="regular"
/>
<FooterTrailingAction>
<IconButton
icon={ArrowRightIcon}
ariaLabel="Learn more"
borderColor={theme.colors.primary.border[20]}
iconFillColor="transparent"
iconSize={24}
iconStrokeColor={theme.colors.primary.text[80]}
size={48}
/>
</FooterTrailingAction>
</CardFooter>
)}
</IllustrationCardContainer>
@@ -1,6 +1,10 @@
'use client';
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
import { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { IllustrationCard } from '../IllustrationCard/IllustrationCard';
const IllustrationCardsGrid = styled.div`
@@ -15,6 +19,10 @@ const IllustrationCardsGrid = styled.div`
}
`;
const CardSlot = styled.div`
will-change: transform, opacity;
`;
type IllustrationCardsProps = {
illustrationCards: ThreeCardsIllustrationCardType[];
variant?: 'shaped' | 'simple';
@@ -24,14 +32,28 @@ export function IllustrationCards({
illustrationCards,
variant = 'shaped',
}: IllustrationCardsProps) {
const gridRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<IllustrationCardsGrid>
<IllustrationCardsGrid ref={gridRef}>
<ThreeCardsScrollLayoutEffect
cardCount={illustrationCards.length}
cardRefs={cardRefs}
gridRef={gridRef}
/>
{illustrationCards.map((illustrationCard, index) => (
<IllustrationCard
<CardSlot
key={index}
variant={variant}
illustrationCard={illustrationCard}
/>
ref={(element) => {
cardRefs.current[index] = element;
}}
>
<IllustrationCard
variant={variant}
illustrationCard={illustrationCard}
/>
</CardSlot>
))}
</IllustrationCardsGrid>
);
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import {
applyThreeCardsScrollLayout,
type ThreeCardsScrollLayoutRefs,
} from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
type ThreeCardsScrollLayoutEffectProps = ThreeCardsScrollLayoutRefs & {
cardCount: number;
};
export function ThreeCardsScrollLayoutEffect({
cardCount,
cardRefs,
gridRef,
}: ThreeCardsScrollLayoutEffectProps) {
useEffect(() => {
const refs: ThreeCardsScrollLayoutRefs = { cardRefs, gridRef };
let rafId: number | null = null;
const flushLayout = () => {
rafId = null;
applyThreeCardsScrollLayout(refs, cardCount);
};
const scheduleLayout = () => {
if (rafId !== null) {
return;
}
rafId = window.requestAnimationFrame(flushLayout);
};
applyThreeCardsScrollLayout(refs, cardCount);
window.addEventListener('scroll', scheduleLayout, { passive: true });
window.addEventListener('resize', scheduleLayout);
return () => {
window.removeEventListener('scroll', scheduleLayout);
window.removeEventListener('resize', scheduleLayout);
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [cardCount, cardRefs, gridRef]);
return null;
}
@@ -0,0 +1,69 @@
import type { RefObject } from 'react';
import { theme } from '@/theme';
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function easeOutQuint(t: number) {
return 1 - Math.pow(1 - t, 5);
}
export type ThreeCardsScrollLayoutRefs = {
cardRefs: RefObject<(HTMLDivElement | null)[]>;
gridRef: RefObject<HTMLDivElement | null>;
};
const STAGGER = 0.25;
export function applyThreeCardsScrollLayout(
refs: ThreeCardsScrollLayoutRefs,
cardCount: number,
): void {
const grid = refs.gridRef.current;
if (!grid) {
return;
}
const isDesktop = window.matchMedia(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
if (!isDesktop) {
for (let index = 0; index < cardCount; index++) {
const node = refs.cardRefs.current[index];
if (!node) {
continue;
}
node.style.opacity = '1';
node.style.transform = 'none';
}
return;
}
const rect = grid.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const startEdge = viewportHeight;
const endEdge = viewportHeight * 0.2;
const progress = clamp01((startEdge - rect.top) / (startEdge - endEdge));
for (let index = 0; index < cardCount; index++) {
const node = refs.cardRefs.current[index];
if (!node) {
continue;
}
const delay = index * STAGGER;
const localProgress = clamp01((progress - delay) / (1 - delay));
const eased = easeOutQuint(localProgress);
const translateY = (1 - eased) * 200;
const scale = 0.88 + eased * 0.12;
const opacity = clamp01(localProgress / 0.4);
node.style.opacity = String(opacity);
node.style.transform = `translateY(${translateY}px) scale(${scale})`;
}
}
@@ -17,6 +17,10 @@ const ContentRoot = styled.div`
align-self: stretch;
gap: ${theme.spacing(20)};
margin-left: calc(-1 * ${theme.spacing(4)});
position: sticky;
top: 0;
height: 100vh;
align-items: center;
}
`;
@@ -29,8 +33,6 @@ const StepsColumn = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
height: max-content;
max-width: 556px;
position: sticky;
top: calc(50vh - 150px);
}
`;
@@ -3,10 +3,24 @@
import { IllustrationMount } from '@/illustrations';
import { SyncScrollProgressFromContainerEffect } from '@/sections/WhyTwentyStepper/effect-components/SyncScrollProgressFromContainerEffect';
import type { WhyTwentyStepperDataType } from '@/sections/WhyTwentyStepper/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import { Content } from '../Content/Content';
import { Root } from '../Root/Root';
const IllustrationColumn = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
type FlowProps = WhyTwentyStepperDataType;
export function Flow({ body, heading, illustration }: FlowProps) {
@@ -32,7 +46,9 @@ export function Flow({ body, heading, illustration }: FlowProps) {
heading={heading}
localProgress={localProgress}
/>
<IllustrationMount illustration={illustration} />
<IllustrationColumn>
<IllustrationMount illustration={illustration} />
</IllustrationColumn>
</Root>
);
}