Merge branch 'main' into mcp-streamable-http-405
This commit is contained in:
+15
-5
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+16
-10
@@ -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 };
|
||||
};
|
||||
|
||||
+4
-4
@@ -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');
|
||||
|
||||
+15
-7
@@ -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,
|
||||
|
||||
+14
-12
@@ -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 };
|
||||
|
||||
+1
-1
@@ -227,7 +227,7 @@ export const usePageLayoutHeaderInfo = ({
|
||||
return {
|
||||
headerIcon: IconTable,
|
||||
headerIconColor: iconColor,
|
||||
headerType: t`Record Table`,
|
||||
headerType: t`View`,
|
||||
title,
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
|
||||
+26
-4
@@ -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}
|
||||
/>
|
||||
|
||||
+42
-13
@@ -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>
|
||||
|
||||
+8
-7
@@ -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();
|
||||
|
||||
+114
@@ -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>
|
||||
|
||||
+1
-3
@@ -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.05–0.55, Card 2: 0.35–0.85, Card 3: 0.65–1.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>
|
||||
</>
|
||||
);
|
||||
|
||||
+119
-26
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
+68
-16
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+41
@@ -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 0–345, transition 1→2: frames 346–415
|
||||
// Step 2 content: frames 416–933, transition 2→3: frames 934–980
|
||||
// Step 3 content: frames 981–end
|
||||
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[];
|
||||
};
|
||||
|
||||
+4
-2
@@ -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)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
+2
-1
@@ -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
-143
@@ -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>
|
||||
|
||||
+25
-2
@@ -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>
|
||||
);
|
||||
|
||||
+4
-21
@@ -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>
|
||||
|
||||
+27
-5
@@ -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>
|
||||
);
|
||||
|
||||
+49
@@ -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;
|
||||
}
|
||||
+69
@@ -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})`;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user