few website updates (#19663)

## Summary
- refresh pricing page content, plan cards, CTA styling, and Salesforce
comparison visuals
- update partner and hero/testimonial visuals, including pulled
carousel-compatible partner testimonial data
- improve halftone export and illustration mounting flows, plus related
button and hydration fixes
- add updated website illustration and pricing assets

## Testing
- Not run (not requested)

---------

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-04-14 08:23:20 +02:00
committed by GitHub
parent e041125426
commit 87f8e5ca19
53 changed files with 3817 additions and 6010 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -22,7 +22,12 @@ export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
const { openContactCalModal } = useContactCalModal();
return (
<StyledTrigger type="button" onClick={openContactCalModal}>
<StyledTrigger
data-color={color}
data-variant={variant}
type="button"
onClick={openContactCalModal}
>
<BaseButton color={color} label={label} variant={variant} />
</StyledTrigger>
);
@@ -1,5 +1,6 @@
'use client';
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
import { styled } from '@linaria/react';
import type {
@@ -33,6 +34,11 @@ type ControlsPanelProps = {
onExportNameChange: (value: string) => void;
onExportReact: () => void;
onImportPreset: () => void;
onReactAssetPublicUrlChange: (value: string) => void;
onReactExportSettingChange: (
key: keyof ReactExportSettings,
value: boolean,
) => void;
onHalftoneChange: (
value: Partial<HalftoneStudioSettings['halftone']>,
) => void;
@@ -49,10 +55,14 @@ type ControlsPanelProps = {
onToggleVisibility: () => void;
onUploadSource: () => void;
previewDistance: number;
reactAssetPublicUrl: string;
reactExportSettings: ReactExportSettings;
visible: boolean;
selectedShape: HalftoneGeometrySpec | undefined;
settings: HalftoneStudioSettings;
shapeOptions: Array<{ label: string; value: string }>;
defaultReactAssetPublicUrl: string;
showReactAssetPublicUrl: boolean;
};
const TABS: HalftoneTabId[] = ['design', 'animations', 'export'];
@@ -129,6 +139,8 @@ export function ControlsPanel({
onExportNameChange,
onExportReact,
onImportPreset,
onReactAssetPublicUrlChange,
onReactExportSettingChange,
onHalftoneChange,
onLightingChange,
onMaterialChange,
@@ -139,10 +151,14 @@ export function ControlsPanel({
onToggleVisibility,
onUploadSource,
previewDistance,
reactAssetPublicUrl,
reactExportSettings,
visible,
selectedShape,
settings,
shapeOptions,
defaultReactAssetPublicUrl,
showReactAssetPublicUrl,
}: ControlsPanelProps) {
return (
<PanelShell $collapsed={!visible}>
@@ -226,8 +242,14 @@ export function ControlsPanel({
onExportNameChange={onExportNameChange}
onExportReact={onExportReact}
onImportPreset={onImportPreset}
onReactAssetPublicUrlChange={onReactAssetPublicUrlChange}
onReactExportSettingChange={onReactExportSettingChange}
reactAssetPublicUrl={reactAssetPublicUrl}
reactExportSettings={reactExportSettings}
selectedShape={selectedShape}
settings={settings}
defaultReactAssetPublicUrl={defaultReactAssetPublicUrl}
showReactAssetPublicUrl={showReactAssetPublicUrl}
/>
) : null}
</PanelShell>
@@ -13,11 +13,13 @@ import {
} from '@/app/halftone/_lib/geometry-registry';
import { REFERENCE_PREVIEW_DISTANCE } from '@/app/halftone/_lib/footprint';
import {
DEFAULT_REACT_EXPORT_SETTINGS,
deriveExportComponentName,
generateReactComponent,
generateStandaloneHtml,
getExportedModelFile,
parseExportedPreset,
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import {
DEFAULT_IMAGE_HALFTONE_SETTINGS,
@@ -203,6 +205,25 @@ function downloadText(filename: string, content: string) {
downloadBlob(filename, new Blob([content], { type: 'text/plain' }));
}
function getFilenameExtension(
filename: string | null | undefined,
fallbackExtension: string,
) {
const sanitizedFilename = filename?.split(/[?#]/, 1)[0] ?? '';
const match = sanitizedFilename.match(/(\.[^.\\/]+)$/);
return match?.[1] ?? fallbackExtension;
}
function getAssetFilenameFromUrl(assetUrl: string, fallbackFilename: string) {
const sanitizedAssetUrl = assetUrl.split(/[?#]/, 1)[0].replace(/\/+$/, '');
const assetFilename = sanitizedAssetUrl.split('/').filter(Boolean).pop();
return assetFilename && assetFilename.length > 0
? assetFilename
: fallbackFilename;
}
function createInitialExportPose(): HalftoneExportPose {
return {
autoElapsed: 0,
@@ -291,6 +312,9 @@ export function HalftoneStudio() {
);
const [exportBackground, setExportBackground] = useState(false);
const [exportName, setExportName] = useState('');
const [reactExportSettings, setReactExportSettings] =
useState<ReactExportSettings>(DEFAULT_REACT_EXPORT_SETTINGS);
const [reactAssetPublicUrl, setReactAssetPublicUrl] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
null,
@@ -362,6 +386,38 @@ export function HalftoneStudio() {
() => resolveExportArtifactNames(exportName, defaultExportName),
[defaultExportName, exportName],
);
const reactExportUsesExternalAsset = useMemo(
() =>
state.settings.sourceMode === 'image' ||
selectedShape?.kind === 'imported',
[selectedShape?.kind, state.settings.sourceMode],
);
const defaultReactAssetPublicUrl = useMemo(() => {
if (!reactExportUsesExternalAsset) {
return '';
}
const assetExtension =
state.settings.sourceMode === 'image'
? getFilenameExtension(
imageFile?.name ?? DEFAULT_IMAGE_FILENAME,
'.svg',
)
: getFilenameExtension(
selectedImportedFile?.name ?? selectedShape?.filename,
selectedShape?.loader === 'fbx' ? '.fbx' : '.glb',
);
return `/illustrations/generated/${exportArtifactNames.fileBaseName}${assetExtension}`;
}, [
exportArtifactNames.fileBaseName,
imageFile?.name,
reactExportUsesExternalAsset,
selectedImportedFile?.name,
selectedShape?.filename,
selectedShape?.loader,
state.settings.sourceMode,
]);
useEffect(() => {
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
@@ -831,6 +887,16 @@ export function HalftoneStudio() {
exportPoseReference.current = pose;
}, []);
const handleReactExportSettingChange = useCallback(
(key: keyof ReactExportSettings, value: boolean) => {
setReactExportSettings((currentSettings) => ({
...currentSettings,
[key]: value,
}));
},
[],
);
const handleExportReact = useCallback(() => {
const componentName = exportArtifactNames.componentName;
const kebabName = exportArtifactNames.fileBaseName;
@@ -839,28 +905,45 @@ export function HalftoneStudio() {
const exportBackgroundColor = exportBackground
? state.settings.background.color
: 'transparent';
const effectiveReactAssetPublicUrl =
reactExportSettings.includePublicAssetUrl && reactExportUsesExternalAsset
? reactAssetPublicUrl.trim() || defaultReactAssetPublicUrl
: undefined;
const modelFilename = importedFile
const defaultModelFilename = importedFile
? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}`
: undefined;
const modelFilename =
effectiveReactAssetPublicUrl && defaultModelFilename
? getAssetFilenameFromUrl(
effectiveReactAssetPublicUrl,
defaultModelFilename,
)
: defaultModelFilename;
const imageExportFilename = imageFile
const defaultImageExportFilename = imageFile
? `${kebabName}${imageFile.name.replace(/^[^.]*/, '')}`
: undefined;
const imageExportFilename =
effectiveReactAssetPublicUrl && defaultImageExportFilename
? getAssetFilenameFromUrl(
effectiveReactAssetPublicUrl,
defaultImageExportFilename,
)
: defaultImageExportFilename;
downloadText(
`${componentName}.tsx`,
generateReactComponent(
state.settings,
selectedShape,
componentName,
modelFilename,
exportPoseReference.current,
generateReactComponent(state.settings, selectedShape, componentName, {
assetUrl: effectiveReactAssetPublicUrl,
background: exportBackgroundColor,
imageFilename: imageExportFilename,
importedFile: importedFile ?? undefined,
initialPose: exportPoseReference.current,
modelFilenameOverride: modelFilename,
exportSettings: reactExportSettings,
previewDistance,
importedFile ?? undefined,
imageExportFilename,
exportBackgroundColor,
),
}),
);
if (isImageMode && imageFile) {
@@ -869,10 +952,14 @@ export function HalftoneStudio() {
downloadBlob(modelFilename ?? importedFile.name, importedFile);
}
}, [
defaultReactAssetPublicUrl,
exportArtifactNames,
exportBackground,
imageFile,
previewDistance,
reactAssetPublicUrl,
reactExportSettings,
reactExportUsesExternalAsset,
selectedImportedFile,
selectedShape,
state.settings,
@@ -928,12 +1015,14 @@ export function HalftoneStudio() {
state.settings,
selectedShape,
componentName,
modelFilename,
exportPoseReference.current,
previewDistance,
importedFile ?? undefined,
imageExportFilename,
exportBackgroundColor,
{
background: exportBackgroundColor,
imageFilename: imageExportFilename,
importedFile: importedFile ?? undefined,
initialPose: exportPoseReference.current,
modelFilenameOverride: modelFilename,
previewDistance,
},
),
);
@@ -1124,6 +1213,8 @@ export function HalftoneStudio() {
}}
onExportNameChange={setExportName}
onExportReact={handleExportReact}
onReactAssetPublicUrlChange={setReactAssetPublicUrl}
onReactExportSettingChange={handleReactExportSettingChange}
onImportPreset={() => {
void handleImportPreset();
}}
@@ -1150,9 +1241,16 @@ export function HalftoneStudio() {
void handleUploadSource();
}}
previewDistance={previewDistance}
reactAssetPublicUrl={reactAssetPublicUrl}
reactExportSettings={reactExportSettings}
selectedShape={selectedShape}
settings={state.settings}
shapeOptions={shapeOptions}
defaultReactAssetPublicUrl={defaultReactAssetPublicUrl}
showReactAssetPublicUrl={
reactExportSettings.includePublicAssetUrl &&
reactExportUsesExternalAsset
}
visible={controlsVisible}
/>
</ControlsPanelFrame>
@@ -1,5 +1,6 @@
'use client';
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import type {
@@ -28,6 +29,48 @@ const RESOLUTION_OPTIONS = [
];
const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg';
const DEFAULT_IMAGE_LABEL = 'Twenty image';
const REACT_EXPORT_SETTING_OPTIONS: Array<{
description: string;
key: keyof ReactExportSettings;
label: string;
}> = [
{
key: 'includePublicAssetUrl',
label: 'Use public asset URL',
description:
'Bakes the public asset path into the React export instead of using a relative file path.',
},
{
key: 'includeStyledMount',
label: 'Use Linaria wrapper',
description:
'Wraps the mount node with a StyledVisualMount using @linaria/react and keeps the Twenty-ready mount shape.',
},
{
key: 'includeUseClientDirective',
label: "Add 'use client'",
description:
'Prepends the Next.js client directive so the exported component can be dropped into the Twenty website directly.',
},
{
key: 'includeTsNoCheck',
label: 'Add @ts-nocheck',
description:
'Prepends // @ts-nocheck so generated self-contained files do not need hand-cleaning to satisfy strict TypeScript.',
},
{
key: 'includeNamedAndDefaultExport',
label: 'Named + default export',
description:
'Exports both a named component and a default export to match the current Twenty illustration import pattern.',
},
{
key: 'includeRegistryComment',
label: 'Add Twenty header comment',
description:
'Adds suggested destination and registry wiring comments at the top of the generated file.',
},
];
function sectionLabel(label: string, description: string) {
return <LabelWithTooltip description={description} label={label} />;
@@ -44,8 +87,17 @@ type ExportTabProps = {
onExportNameChange: (value: string) => void;
onExportReact: () => void;
onImportPreset: () => void;
onReactAssetPublicUrlChange: (value: string) => void;
onReactExportSettingChange: (
key: keyof ReactExportSettings,
value: boolean,
) => void;
reactAssetPublicUrl: string;
reactExportSettings: ReactExportSettings;
selectedShape: HalftoneGeometrySpec | undefined;
settings: HalftoneStudioSettings;
defaultReactAssetPublicUrl: string;
showReactAssetPublicUrl: boolean;
};
export function ExportTab({
@@ -59,8 +111,14 @@ export function ExportTab({
onExportNameChange,
onExportReact,
onImportPreset,
onReactAssetPublicUrlChange,
onReactExportSettingChange,
reactAssetPublicUrl,
reactExportSettings,
selectedShape,
settings,
defaultReactAssetPublicUrl,
showReactAssetPublicUrl,
}: ExportTabProps) {
const [resolution, setResolution] = useState('1920x1080');
const isImageMode = settings.sourceMode === 'image';
@@ -170,6 +228,20 @@ export function ExportTab({
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Source: ${sourceLabel}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Public asset URL: ${reactExportSettings.includePublicAssetUrl ? 'on' : 'off'}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Linaria wrapper: ${reactExportSettings.includeStyledMount ? 'on' : 'off'}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// use client: ${reactExportSettings.includeUseClientDirective ? 'on' : 'off'}`}
</div>
{showReactAssetPublicUrl ? (
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Asset URL: ${reactAssetPublicUrl || defaultReactAssetPublicUrl}`}
</div>
) : null}
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Animation: ${animationLabel}`}
</div>
@@ -190,6 +262,36 @@ export function ExportTab({
</div>
</ExportPreview>
{REACT_EXPORT_SETTING_OPTIONS.map((option) => (
<ToggleControl
checked={reactExportSettings[option.key]}
key={option.key}
label={sectionLabel(option.label, option.description)}
onChange={(event) =>
onReactExportSettingChange(option.key, event.target.checked)
}
/>
))}
{showReactAssetPublicUrl ? (
<div style={{ marginTop: 12 }}>
<SectionTitle $preserveCase>
{sectionLabel(
'Asset public URL',
'Used as the baked-in public path for the downloaded image or model asset when "Use public asset URL" is enabled.',
)}
</SectionTitle>
<ExportNameInput
onChange={(event) =>
onReactAssetPublicUrlChange(event.target.value)
}
placeholder={defaultReactAssetPublicUrl}
type="text"
value={reactAssetPublicUrl}
/>
</div>
) : null}
<ExportButton onClick={onExportReact} type="button">
Download React Component
</ExportButton>
@@ -1,14 +1,33 @@
import {
generateReactComponent,
parseExportedPreset,
generateStandaloneHtml,
parseExportedPreset,
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import {
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneGeometrySpec,
} from '@/app/halftone/_lib/state';
const IMPORTED_GLB_SHAPE: HalftoneGeometrySpec = {
key: 'userUpload_connect',
label: 'connect.glb',
kind: 'imported',
loader: 'glb',
filename: 'connect.glb',
};
const GENERIC_REACT_EXPORT_SETTINGS: ReactExportSettings = {
includeNamedAndDefaultExport: false,
includePublicAssetUrl: false,
includeRegistryComment: false,
includeTsNoCheck: false,
includeUseClientDirective: false,
includeStyledMount: false,
};
describe('halftone export naming', () => {
it('normalizes free-form export names into safe component and file names', () => {
expect(resolveExportArtifactNames('hero export 2026')).toEqual({
@@ -17,15 +36,17 @@ describe('halftone export naming', () => {
});
});
it('sanitizes generated React component identifiers', () => {
it('sanitizes generated React component identifiers for the default Twenty export', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'hero export 2026',
);
expect(output).toContain('// @ts-nocheck');
expect(output).toContain('export function HeroExport2026({');
expect(output).toContain('export default HeroExport2026;');
expect(output).toContain('type HeroExport2026Props = {');
expect(output).toContain('export default function HeroExport2026({');
expect(output).not.toContain('type hero export 2026Props = {');
});
@@ -56,4 +77,130 @@ describe('halftone export naming', () => {
normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor,
).toBe(DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor);
});
it('uses the initial pose as the export runtime rotation baseline', async () => {
const reactOutput = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'rotation baseline export',
);
const htmlOutput = await generateStandaloneHtml(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'rotation baseline export',
);
expect(reactOutput).toContain('let baseRotationY = initialPose.rotationY;');
expect(htmlOutput).toContain('let baseRotationY = initialPose.rotationY;');
});
});
describe('halftone react export presets', () => {
it('emits a Twenty-ready GLB export with Draco support and no torus fallback', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
IMPORTED_GLB_SHAPE,
'PartnerConnect',
{
assetUrl: '/illustrations/generated/partner-connect.glb',
modelFilenameOverride: 'partner-connect.glb',
},
);
expect(output).toContain('// @ts-nocheck');
expect(output).toContain("'use client';");
expect(output).toContain('DRACOLoader');
expect(output).toContain('export function PartnerConnect({');
expect(output).toContain('export default PartnerConnect;');
expect(output).toContain(
'modelUrl = "/illustrations/generated/partner-connect.glb"',
);
expect(output).toContain(
'// Suggested public asset destination: public/illustrations/generated/partner-connect.glb',
);
expect(output).not.toContain("createBuiltinGeometry('torusKnot')");
});
it('emits a Twenty-ready image export without model loaders', () => {
const imageSettings = normalizeHalftoneStudioSettings({
...DEFAULT_HALFTONE_SETTINGS,
sourceMode: 'image',
});
const output = generateReactComponent(
imageSettings,
undefined,
'PartnerLogo',
{
assetUrl: '/illustrations/generated/partner-logo.svg',
imageFilename: 'partner-logo.svg',
},
);
expect(output).toContain(
'imageUrl = "/illustrations/generated/partner-logo.svg"',
);
expect(output).not.toContain('GLTFLoader');
expect(output).not.toContain('FBXLoader');
expect(output).not.toContain('DRACOLoader');
});
it('keeps pointer cancel wiring and removes window listeners from window in the model runtime', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
IMPORTED_GLB_SHAPE,
'PartnerConnect',
{
assetUrl: '/illustrations/generated/partner-connect.glb',
modelFilenameOverride: 'partner-connect.glb',
},
);
expect(output).toContain('const handlePointerCancel = () =>');
expect(output).toContain(
"canvas.addEventListener('pointercancel', handlePointerCancel);",
);
expect(output).toContain(
"window.removeEventListener('pointerup', handlePointerUp);",
);
expect(output).toContain(
"window.removeEventListener('pointermove', handleWindowPointerMove);",
);
expect(output).not.toContain(
"canvas.removeEventListener('pointerup', handlePointerUp);",
);
});
it('parses Twenty exports with header comments and named/default exports', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
IMPORTED_GLB_SHAPE,
'PartnerConnect',
{
assetUrl: '/illustrations/generated/partner-connect.glb',
modelFilenameOverride: 'partner-connect.glb',
},
);
const parsed = parseExportedPreset(output);
expect(parsed.componentName).toBe('PartnerConnect');
expect(parsed.modelAssetReference).toBe(
'/illustrations/generated/partner-connect.glb',
);
});
it('keeps the generic React export available with relative asset paths', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
IMPORTED_GLB_SHAPE,
'PartnerConnect',
{
exportSettings: GENERIC_REACT_EXPORT_SETTINGS,
modelFilenameOverride: 'partner-connect.glb',
},
);
expect(output).toContain('modelUrl = "./partner-connect.glb"');
expect(output).toContain('export default function PartnerConnect({');
expect(output).not.toContain("'use client';");
});
});
@@ -13,6 +13,35 @@ import {
} from '@/app/halftone/_lib/state';
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
export type ReactExportSettings = {
includeNamedAndDefaultExport: boolean;
includePublicAssetUrl: boolean;
includeRegistryComment: boolean;
includeTsNoCheck: boolean;
includeUseClientDirective: boolean;
includeStyledMount: boolean;
};
export type ReactExportOptions = {
assetUrl?: string;
background?: string;
exportSettings?: Partial<ReactExportSettings>;
imageFilename?: string;
importedFile?: File;
initialPose?: HalftoneExportPose;
modelFilenameOverride?: string;
previewDistance?: number;
};
export const DEFAULT_REACT_EXPORT_SETTINGS: ReactExportSettings = {
includeNamedAndDefaultExport: true,
includePublicAssetUrl: true,
includeRegistryComment: true,
includeTsNoCheck: true,
includeUseClientDirective: true,
includeStyledMount: true,
};
const passThroughVertexShader = `
varying vec2 vUv;
@@ -1021,7 +1050,7 @@ function createBuiltinGeometry(shapeKey) {
}
`;
const IMPORTED_RUNTIME_SOURCE = String.raw`
const IMPORTED_RUNTIME_SHARED_SOURCE = String.raw`
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
@@ -1098,6 +1127,9 @@ function extractMergedGeometry(root, emptyMessage) {
return normalizeImportedGeometry(mergeGeometries(geometries));
}
`;
const IMPORTED_FBX_RUNTIME_SOURCE = String.raw`
function parseFbxGeometry(buffer, label) {
const originalWarn = console.warn;
@@ -1117,10 +1149,26 @@ function parseFbxGeometry(buffer, label) {
console.warn = originalWarn;
}
}
`;
const IMPORTED_GLB_RUNTIME_SOURCE = String.raw`
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
function parseGlbGeometry(buffer, label) {
return new Promise((resolve, reject) => {
new GLTFLoader(createLoadingManager()).parse(
const loadingManager = createLoadingManager();
const dracoLoader = new DRACOLoader(loadingManager);
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
const loader = new GLTFLoader(loadingManager);
loader.setDRACOLoader(dracoLoader);
const cleanup = () => {
dracoLoader.dispose();
};
loader.parse(
buffer,
'',
(gltf) => {
@@ -1133,14 +1181,29 @@ function parseGlbGeometry(buffer, label) {
);
} catch (error) {
reject(error);
} finally {
cleanup();
}
},
reject,
(error) => {
cleanup();
reject(error);
},
);
});
}
`;
async function loadImportedGeometryFromUrl(loader, modelUrl, label) {
function createImportedRuntimeSource(loader: HalftoneGeometrySpec['loader']) {
const loaderSource =
loader === 'fbx'
? IMPORTED_FBX_RUNTIME_SOURCE
: IMPORTED_GLB_RUNTIME_SOURCE;
return `${IMPORTED_RUNTIME_SHARED_SOURCE}
${loaderSource}
async function loadImportedGeometryFromUrl(modelUrl, label) {
const response = await fetch(modelUrl);
if (!response.ok) {
@@ -1149,13 +1212,14 @@ async function loadImportedGeometryFromUrl(loader, modelUrl, label) {
const buffer = await response.arrayBuffer();
if (loader === 'fbx') {
return parseFbxGeometry(buffer, label);
${
loader === 'fbx'
? 'return parseFbxGeometry(buffer, label);'
: 'return parseGlbGeometry(buffer, label);'
}
return parseGlbGeometry(buffer, label);
}
`;
}
type ExportedShapeDescriptor = {
filename: string | null;
@@ -1175,6 +1239,147 @@ export type ParsedExportedPreset = {
shape: ExportedShapeDescriptor;
};
function getExportedShapeLoader(
shape: ExportedShapeDescriptor,
): HalftoneGeometrySpec['loader'] | null {
if (shape.loader) {
return shape.loader;
}
const filename = shape.filename?.toLowerCase() ?? '';
if (filename.endsWith('.fbx')) {
return 'fbx';
}
if (filename.endsWith('.glb')) {
return 'glb';
}
return null;
}
function createImportedGeometryRuntimeSource(shape: ExportedShapeDescriptor) {
if (shape.kind !== 'imported') {
return '';
}
const loader = getExportedShapeLoader(shape) ?? 'glb';
return createImportedRuntimeSource(loader);
}
function toIllustrationRegistryKey(componentName: string) {
return componentName.charAt(0).toLowerCase() + componentName.slice(1);
}
function toPublicAssetDestination(assetUrl: string) {
return assetUrl.startsWith('/') ? `public${assetUrl}` : assetUrl;
}
function normalizeReactExportSettings(
exportSettings?: Partial<ReactExportSettings>,
): ReactExportSettings {
return {
...DEFAULT_REACT_EXPORT_SETTINGS,
...exportSettings,
};
}
function getReactImportBlock(
exportSettings: ReactExportSettings,
isImageMode: boolean,
shape: ExportedShapeDescriptor,
) {
const imports = new Set<string>();
imports.add("import { useEffect, useRef, type CSSProperties } from 'react';");
imports.add("import * as THREE from 'three';");
if (exportSettings.includeStyledMount) {
imports.add("import { styled } from '@linaria/react';");
}
if (!isImageMode) {
imports.add(
"import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';",
);
}
const loader = getExportedShapeLoader(shape);
if (shape.kind === 'imported' && loader === 'fbx') {
imports.add(
"import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';",
);
}
if (shape.kind === 'imported' && loader === 'glb') {
imports.add(
"import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';",
);
imports.add(
"import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';",
);
}
return Array.from(imports).join('\n');
}
function getStandaloneThreeImports(
isImageMode: boolean,
shape: ExportedShapeDescriptor,
) {
if (isImageMode) {
return `import * as THREE from 'three';`;
}
const imports = [
`import * as THREE from 'three';`,
`import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';`,
];
const loader = getExportedShapeLoader(shape);
if (shape.kind === 'imported' && loader === 'fbx') {
imports.push(
`import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';`,
);
}
if (shape.kind === 'imported' && loader === 'glb') {
imports.push(
`import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';`,
`import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`,
);
}
return imports.join('\n ');
}
function getTwentyReactHeaderComment(
componentName: string,
registryKey: string,
assetUrl?: string,
) {
const lines = [
`// Suggested component destination: src/illustrations/${componentName}.tsx`,
];
if (assetUrl) {
lines.push(
`// Suggested public asset destination: ${toPublicAssetDestination(assetUrl)}`,
);
}
lines.push(
`// illustrations-registry.tsx: import { ${componentName} } from './${componentName}';`,
`// illustrations-registry.tsx: ${registryKey}: ${componentName},`,
);
return `${lines.join('\n')}\n`;
}
function createDefaultExportPose(): HalftoneExportPose {
return {
autoElapsed: 0,
@@ -1318,6 +1523,8 @@ export function parseExportedPreset(content: string): ParsedExportedPreset {
const componentName =
extractFirstMatch(content, [
/export\s+default\s+function\s+([A-Za-z0-9_]+)/,
/export\s+function\s+([A-Za-z0-9_]+)/,
/export\s+default\s+([A-Za-z0-9_]+)\s*;/,
/<title>([^<]+)<\/title>/i,
]) ?? null;
const modelAssetReference = extractFirstMatch(content, [
@@ -1817,7 +2024,7 @@ ${HALFTONE_FOOTPRINT_RUNTIME_SOURCE}
${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE}
${isImageMode ? '' : IMPORTED_RUNTIME_SOURCE}
${isImageMode ? '' : createImportedGeometryRuntimeSource(shape)}
${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE}
@@ -1891,8 +2098,12 @@ function resetInteractionState(interactionState) {
}
async function createGeometry(modelUrl) {
if (shape.kind === 'imported' && shape.loader && modelUrl) {
return loadImportedGeometryFromUrl(shape.loader, modelUrl, shape.label);
if (shape.kind === 'imported') {
if (!modelUrl) {
throw new Error('No model URL was provided for ' + shape.label + '.');
}
return loadImportedGeometryFromUrl(modelUrl, shape.label);
}
return createBuiltinGeometry(shape.key);
@@ -1926,7 +2137,7 @@ async function mountHalftoneCanvas(options) {
geometry = await createGeometry(modelUrl);
} catch (error) {
onError?.(error);
geometry = createBuiltinGeometry('torusKnot');
return () => {};
}
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
@@ -2204,6 +2415,14 @@ async function mountHalftoneCanvas(options) {
interaction.velocityY = 0;
};
const handlePointerCancel = () => {
interaction.dragging = false;
interaction.velocityX = 0;
interaction.velocityY = 0;
canvas.style.cursor = followDragEnabled ? 'grab' : 'default';
handlePointerLeave();
};
const handleWindowBlur = () => {
handlePointerUp();
handlePointerLeave();
@@ -2211,6 +2430,7 @@ async function mountHalftoneCanvas(options) {
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointercancel', handlePointerCancel);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointermove', handleWindowPointerMove);
window.addEventListener('blur', handleWindowBlur);
@@ -2228,9 +2448,9 @@ async function mountHalftoneCanvas(options) {
const elapsedTime = initialPose.timeElapsed + clock.getElapsed();
halftoneMaterial.uniforms.time.value = elapsedTime;
let baseRotationX = 0;
let baseRotationY = 0;
let baseRotationZ = 0;
let baseRotationX = initialPose.rotationX;
let baseRotationY = initialPose.rotationY;
let baseRotationZ = initialPose.rotationZ;
let meshOffsetY = 0;
let meshScale = 1;
let lightAngle = settings.lighting.angleDegrees;
@@ -2476,7 +2696,8 @@ async function mountHalftoneCanvas(options) {
resizeObserver.disconnect();
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointermove', handleWindowPointerMove);
canvas.removeEventListener('pointercancel', handlePointerCancel);
window.removeEventListener('blur', handleWindowBlur);
canvas.removeEventListener('pointerdown', handlePointerDown);
@@ -2915,65 +3136,87 @@ export function generateReactComponent(
settings: HalftoneStudioSettings,
selectedShape: HalftoneGeometrySpec | undefined,
componentName = 'HalftoneDashes',
modelFilenameOverride?: string,
initialPose?: HalftoneExportPose,
previewDistance?: number,
importedFile?: File,
imageFilename?: string,
background = 'transparent',
options: ReactExportOptions = {},
) {
const isImageMode = settings.sourceMode === 'image';
const exportSettings = normalizeReactExportSettings(options.exportSettings);
const shape = createShapeDescriptor(
selectedShape,
settings,
importedFile,
modelFilenameOverride,
options.importedFile,
options.modelFilenameOverride,
);
const pose = normalizeExportPose(initialPose);
const pose = normalizeExportPose(options.initialPose);
const normalizedComponentName = normalizeExportComponentName(componentName);
const defaultModelUrl =
modelFilenameOverride ?? shape.filename ?? 'model.glb';
const defaultImageUrl = imageFilename ?? 'image.png';
if (isImageMode) {
return `import { useEffect, useRef, type CSSProperties } from 'react';
import * as THREE from 'three';
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
${createImageMountScript()}
type ${normalizedComponentName}Props = {
imageUrl?: string;
style?: CSSProperties;
};
export default function ${normalizedComponentName}({
imageUrl = ${JSON.stringify(`./${defaultImageUrl}`)},
style,
}: ${normalizedComponentName}Props) {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const unmount = mountHalftoneCanvas({
container,
imageUrl,
onError: (error) => {
console.error(error);
},
});
return () => {
void Promise.resolve(unmount).then((dispose) => dispose?.());
};
}, [imageUrl]);
return (
const background = options.background ?? 'transparent';
const assetUrl =
exportSettings.includePublicAssetUrl && options.assetUrl
? options.assetUrl
: null;
const defaultModelFilename =
options.modelFilenameOverride ?? shape.filename ?? 'model.glb';
const defaultImageFilename = options.imageFilename ?? 'image.png';
const defaultModelUrl = assetUrl ?? `./${defaultModelFilename}`;
const defaultImageUrl = assetUrl ?? `./${defaultImageFilename}`;
const importBlock = getReactImportBlock(exportSettings, isImageMode, shape);
const headerComment = exportSettings.includeRegistryComment
? getTwentyReactHeaderComment(
normalizedComponentName,
toIllustrationRegistryKey(normalizedComponentName),
isImageMode || shape.kind === 'imported'
? (assetUrl ?? undefined)
: undefined,
)
: '';
const mountScript = isImageMode
? createImageMountScript()
: createMountScript();
const serializedRuntime = serializeRuntimeSource(
settings,
shape,
pose,
options.previewDistance,
);
const directiveLines = [
exportSettings.includeTsNoCheck ? '// @ts-nocheck' : null,
exportSettings.includeUseClientDirective ? "'use client';" : null,
]
.filter((line): line is string => line !== null)
.join('\n');
const mountStyleBlock = exportSettings.includeStyledMount
? `const StyledVisualMount = styled.div\`
background: ${background};
display: block;
height: 100%;
min-width: 0;
width: 100%;
\`;
`
: '';
const assetPropName = isImageMode
? 'imageUrl'
: shape.kind === 'imported'
? 'modelUrl'
: null;
const assetPropDefaultValue =
assetPropName === 'imageUrl'
? defaultImageUrl
: assetPropName === 'modelUrl'
? defaultModelUrl
: null;
const propsTypeBlock = assetPropName
? `type ${normalizedComponentName}Props = {\n ${assetPropName}?: string;\n style?: CSSProperties;\n};`
: `type ${normalizedComponentName}Props = {\n style?: CSSProperties;\n};`;
const propsSignature = assetPropName
? `{\n ${assetPropName} = ${JSON.stringify(assetPropDefaultValue)},\n style,\n}: ${normalizedComponentName}Props`
: `{\n style,\n}: ${normalizedComponentName}Props`;
const mountOptionsBlock = assetPropName
? `const unmount = mountHalftoneCanvas({\n container,\n ${assetPropName},\n onError: (error) => {\n console.error(error);\n },\n });`
: `const unmount = mountHalftoneCanvas({\n container,\n onError: (error) => {\n console.error(error);\n },\n });`;
const effectDependencies = assetPropName ? `[${assetPropName}]` : '[]';
const returnBlock = exportSettings.includeStyledMount
? `return <StyledVisualMount aria-hidden ref={mountReference} style={style} />;`
: `return (
<div
ref={mountReference}
style={{
@@ -2983,30 +3226,9 @@ export default function ${normalizedComponentName}({
...style,
}}
/>
);
}
`;
}
return `import { useEffect, useRef, type CSSProperties } from 'react';
import * as THREE from 'three';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
${createMountScript()}
type ${normalizedComponentName}Props = {
modelUrl?: string;
style?: CSSProperties;
};
export default function ${normalizedComponentName}({
modelUrl = ${JSON.stringify(`./${defaultModelUrl}`)},
style,
}: ${normalizedComponentName}Props) {
);`;
const componentFunctionBlock = exportSettings.includeNamedAndDefaultExport
? `export function ${normalizedComponentName}(${propsSignature}) {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -3016,31 +3238,46 @@ export default function ${normalizedComponentName}({
return;
}
const unmount = mountHalftoneCanvas({
container,
modelUrl,
onError: (error) => {
console.error(error);
},
});
${mountOptionsBlock}
return () => {
void Promise.resolve(unmount).then((dispose) => dispose?.());
};
}, [modelUrl]);
}, ${effectDependencies});
return (
<div
ref={mountReference}
style={{
background: ${JSON.stringify(background)},
height: '100%',
width: '100%',
...style,
}}
/>
);
${returnBlock}
}
export default ${normalizedComponentName};`
: `export default function ${normalizedComponentName}(${propsSignature}) {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
${mountOptionsBlock}
return () => {
void Promise.resolve(unmount).then((dispose) => dispose?.());
};
}, ${effectDependencies});
${returnBlock}
}`;
return `${directiveLines ? `${directiveLines}\n\n` : ''}${importBlock}
${headerComment}${serializedRuntime}
${mountScript}
${mountStyleBlock ? `${mountStyleBlock}\n` : ''}${propsTypeBlock}
${componentFunctionBlock}
`;
}
@@ -3048,39 +3285,30 @@ export async function generateStandaloneHtml(
settings: HalftoneStudioSettings,
selectedShape: HalftoneGeometrySpec | undefined,
componentName = 'HalftoneDashes',
modelFilenameOverride?: string,
initialPose?: HalftoneExportPose,
previewDistance?: number,
importedFile?: File,
imageFilename?: string,
background = 'transparent',
options: ReactExportOptions = {},
) {
const isImageMode = settings.sourceMode === 'image';
const shape = createShapeDescriptor(
selectedShape,
settings,
importedFile,
modelFilenameOverride,
options.importedFile,
options.modelFilenameOverride,
);
const pose = normalizeExportPose(initialPose);
const pose = normalizeExportPose(options.initialPose);
const normalizedComponentName = normalizeExportComponentName(componentName);
const defaultImageUrl = imageFilename ?? 'image.png';
const defaultImageUrl = options.imageFilename ?? 'image.png';
const embeddedImportedModelUrl =
!isImageMode && shape.kind === 'imported' && importedFile
? await fileToDataUrl(importedFile, shape.loader)
!isImageMode && shape.kind === 'imported' && options.importedFile
? await fileToDataUrl(options.importedFile, shape.loader)
: null;
const defaultModelUrl =
embeddedImportedModelUrl ??
modelFilenameOverride ??
options.modelFilenameOverride ??
shape.filename ??
'model.glb';
const background = options.background ?? 'transparent';
const threeImports = isImageMode
? `import * as THREE from 'three';`
: `import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`;
const threeImports = getStandaloneThreeImports(isImageMode, shape);
const mountScript = isImageMode
? createImageMountScript()
@@ -3189,7 +3417,7 @@ export async function generateStandaloneHtml(
<script type="module">
${threeImports}
${serializeRuntimeSource(settings, shape, pose, previewDistance)}
${serializeRuntimeSource(settings, shape, pose, options.previewDistance)}
${mountScript}
@@ -85,6 +85,7 @@ export default async function RootLayout({
<html lang="en">
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
suppressHydrationWarning
>
<ContactCalModalRoot>
<StyledMain>{children}</StyledMain>
@@ -24,12 +24,13 @@ export function BecomePartnerButton({
const { openPartnerApplicationModal } = usePartnerApplicationModal();
return (
<StyledTrigger type="button" onClick={openPartnerApplicationModal}>
<BaseButton
color={color}
label="Become a partner"
variant={variant}
/>
<StyledTrigger
data-color={color}
data-variant={variant}
type="button"
onClick={openPartnerApplicationModal}
>
<BaseButton color={color} label="Become a partner" variant={variant} />
</StyledTrigger>
);
}
@@ -2,10 +2,10 @@ import type { EngagementBandDataType } from '@/sections/EngagementBand/types';
export const ENGAGEMENT_BAND_DATA: EngagementBandDataType = {
heading: {
text: 'Want a tailor-made plan?',
text: 'Need help with customization?',
fontFamily: 'serif',
},
body: {
text: 'Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.',
text: 'Find the right partner to implement, customize, and tailor Twenty to your team.',
},
};
@@ -1,11 +1,13 @@
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroBaseDataType = {
heading: [
{ text: 'A pricing that scales ', fontFamily: 'serif' },
{ text: 'with your need', fontFamily: 'sans' },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
const PRICING_HERO_SUBTAGLINE = {
text: 'Start your free trial today without credit card.',
};
export const HERO_DATA = {
heading: [
{ text: 'Simple', fontFamily: 'serif' },
{ text: ' Pricing', fontFamily: 'sans' },
],
body: PRICING_HERO_SUBTAGLINE,
} satisfies HeroBaseDataType;
@@ -4,37 +4,36 @@ const ORGANIZATION_HEADING = {
text: 'Organization',
};
const PRO_FEATURES_TITLE = { text: 'Key Features' };
const ORGANIZATION_FEATURES_TITLE = { text: 'Everything in Pro +' };
const PRO_BULLETS_DEFAULT = [
{ text: 'Full customisation' },
{ text: 'AI Agents with custom skills' },
{ text: '1K automation credits' },
{ text: 'Standard support' },
{ text: 'Add-on AI (1-month trial)' },
];
const PRO_BULLETS_SELF_HOST_MONTHLY = [
{ text: 'Full customisation' },
{ text: 'AI Agents with custom skills' },
{ text: 'Standard support' },
{ text: 'Add-on AI (1-month trial)' },
{ text: 'Community support' },
];
const PRO_BULLETS_SELF_HOST_YEARLY = [
{ text: 'Full customisation' },
{ text: 'AI Agents with custom skills' },
{ text: 'Standard support' },
{ text: 'Add-on AI (1-month trial)' },
{ text: 'Community support' },
];
const ORGANIZATION_BULLETS = [
const ORGANIZATION_BULLETS_DEFAULT = [
{ text: 'Everything in Pro' },
{ text: 'Roles & Permissions' },
{ text: 'SAML/OIDC SSO' },
{ text: '2K automation credits' },
{ text: 'Priority support' },
{ text: 'Add-on AI full time' },
];
const ORGANIZATION_BULLETS_SELF_HOST = [
{ text: 'Everything in Pro' },
{ text: 'Roles & Permissions' },
{ text: 'SAML/OIDC SSO' },
{ text: 'Priority support' },
];
import type { PlansDataType } from '@/sections/Plans/types';
@@ -44,40 +43,42 @@ export const PLANS_DATA = {
cells: {
cloud: {
monthly: {
featureBullets: ORGANIZATION_BULLETS,
featureBullets: ORGANIZATION_BULLETS_DEFAULT,
price: {
body: { text: '/month, paid monthly' },
heading: { fontFamily: 'sans', text: '$25 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$25' },
},
},
yearly: {
featureBullets: ORGANIZATION_BULLETS,
featureBullets: ORGANIZATION_BULLETS_DEFAULT,
price: {
body: { text: '/month, paid yearly' },
heading: { fontFamily: 'sans', text: '$19 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$19' },
},
},
},
selfHost: {
monthly: {
featureBullets: ORGANIZATION_BULLETS,
featureBullets: ORGANIZATION_BULLETS_SELF_HOST,
price: {
body: { text: '/month, paid monthly' },
heading: { fontFamily: 'sans', text: '$25 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$25' },
},
},
yearly: {
featureBullets: ORGANIZATION_BULLETS,
featureBullets: ORGANIZATION_BULLETS_SELF_HOST,
price: {
body: { text: '/month, paid yearly' },
heading: { fontFamily: 'sans', text: '$19 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$19' },
},
},
},
},
featuresTitle: ORGANIZATION_FEATURES_TITLE,
heading: ORGANIZATION_HEADING,
illustration: 'planOrganization',
icon: {
alt: 'Organization plan icon',
src: '/images/pricing/plans/organization-icon.png',
},
},
pro: {
cells: {
@@ -85,15 +86,15 @@ export const PLANS_DATA = {
monthly: {
featureBullets: PRO_BULLETS_DEFAULT,
price: {
body: { text: '/month, paid monthly' },
heading: { fontFamily: 'sans', text: '$12 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$12' },
},
},
yearly: {
featureBullets: PRO_BULLETS_DEFAULT,
price: {
body: { text: '/month, paid yearly' },
heading: { fontFamily: 'sans', text: '$9 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$9' },
},
},
},
@@ -101,21 +102,24 @@ export const PLANS_DATA = {
monthly: {
featureBullets: PRO_BULLETS_SELF_HOST_MONTHLY,
price: {
body: { text: '/month, paid monthly' },
heading: { fontFamily: 'sans', text: '$0 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$0' },
},
},
yearly: {
featureBullets: PRO_BULLETS_SELF_HOST_YEARLY,
price: {
body: { text: '/month, paid yearly' },
heading: { fontFamily: 'sans', text: '$0 USD' },
body: { text: '/user/month' },
heading: { fontFamily: 'sans', text: '$0' },
},
},
},
},
featuresTitle: PRO_FEATURES_TITLE,
heading: PRO_HEADING,
illustration: 'planPro',
icon: {
alt: 'Pro plan icon',
src: '/images/pricing/plans/pro-icon.png',
width: 60,
},
},
} satisfies PlansDataType;
@@ -148,7 +148,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
rightLabel: 'Out of stock',
tooltip: {
title: 'Out of stock',
body: "Your data prefers someone else's servers.",
body: 'Self-hosting, now for rent!',
},
},
{
@@ -219,7 +219,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
},
],
basePriceAmount: 100,
promoTag: 'Best for\nSalesforce',
promoTag: '1800YESSOFTWARE',
featureSectionHeading: 'Add-ons',
productIconAlt: 'Retro help document icon',
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
@@ -18,8 +18,21 @@ import { Plans } from '@/sections/Plans/components';
import { PlanTable } from '@/sections/PlanTable/components';
import { Salesforce } from '@/sections/Salesforce/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
const PricingPlansContainer = styled.div`
display: grid;
margin: 0 auto;
row-gap: ${theme.spacing(8)};
width: 100%;
`;
const PricingBannerContainer = styled.div`
margin: 0 auto;
width: 100%;
`;
export const metadata: Metadata = {
title: 'Pricing — Twenty',
description:
@@ -50,28 +63,35 @@ export default async function PricingPage() {
</Hero.Root>
<Plans.Root backgroundColor={theme.colors.secondary.background[5]}>
<Plans.Content />
<PricingPlansContainer>
<Plans.Content />
</PricingPlansContainer>
</Plans.Root>
<EngagementBand.Root
backgroundColor={theme.colors.secondary.background[5]}
>
<EngagementBand.Strip
fillColor={theme.colors.primary.background[100]}
variant="primary"
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<TalkToUsButton
color="secondary"
label="Contact us"
variant="contained"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
<PricingBannerContainer>
<EngagementBand.Strip
desktopCopyMaxWidth="60%"
fillColor={theme.colors.primary.background[100]}
variant="primary"
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Find a partner"
type="anchor"
variant="outlined"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</PricingBannerContainer>
</EngagementBand.Root>
<PlanTable.Root backgroundColor={theme.colors.secondary.background[100]}>
@@ -0,0 +1,40 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
type VisibleWhenTabActiveProps = {
children: ReactNode;
};
function getIsTabActive() {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState !== 'hidden';
}
export function VisibleWhenTabActive({
children,
}: VisibleWhenTabActiveProps) {
const [isTabActive, setIsTabActive] = useState(true);
useEffect(() => {
const syncTabVisibility = () => {
setIsTabActive(getIsTabActive());
};
syncTabVisibility();
document.addEventListener('visibilitychange', syncTabVisibility);
return () => {
document.removeEventListener('visibilitychange', syncTabVisibility);
};
}, []);
if (!isTabActive) {
return null;
}
return <>{children}</>;
}
@@ -3,6 +3,9 @@ import { styled } from '@linaria/react';
import { ButtonShape } from './ButtonShape';
export const buttonBaseStyles = `
--button-label-color: ${theme.colors.primary.text[100]};
--button-label-hover-color: ${theme.colors.secondary.text[100]};
align-items: center;
background: transparent;
border: none;
@@ -15,11 +18,39 @@ export const buttonBaseStyles = `
height: ${theme.spacing(10)};
justify-content: center;
letter-spacing: 0;
overflow: hidden;
padding: 0 ${theme.spacing(5)};
position: relative;
text-decoration: none;
text-transform: uppercase;
&[data-variant='contained'][data-color='secondary'] {
--button-label-color: ${theme.colors.secondary.text[100]};
--button-label-hover-color: ${theme.colors.secondary.text[100]};
}
&[data-variant='outlined'][data-color='secondary'] {
--button-label-color: ${theme.colors.primary.text[100]};
--button-label-hover-color: ${theme.colors.primary.text[100]};
}
&[data-variant='outlined'][data-color='primary'] {
--button-label-color: ${theme.colors.secondary.text[100]};
--button-label-hover-color: ${theme.colors.primary.text[100]};
}
&:is(:hover, :focus-visible) {
--button-label-color: var(--button-label-hover-color);
}
&:is(:hover, :focus-visible) [data-slot='button-hover-fill'] {
transform: translateX(0);
}
&[data-variant='outlined'] [data-slot='button-base-shape'] {
z-index: 1;
}
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
@@ -27,16 +58,10 @@ export const buttonBaseStyles = `
`;
const Label = styled.span`
color: var(--button-label-color);
position: relative;
transition: color 220ms ease;
z-index: 1;
&[data-color='primary'] {
color: ${theme.colors.primary.text[100]};
}
&[data-color='secondary'] {
color: ${theme.colors.secondary.text[100]};
}
`;
export type BaseButtonProps = {
@@ -45,31 +70,51 @@ export type BaseButtonProps = {
variant: 'contained' | 'outlined';
};
const secondaryContainedHoverFillColor = theme.colors.secondary.background.hover;
const secondaryOutlinedHoverFillColor = theme.colors.primary.text[100];
const secondaryOutlinedHoverFillOpacity = 0.05;
const HoverFill = styled.span`
inset: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
transform: translateX(calc(-100% - ${theme.spacing(4)}));
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
z-index: 0;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
export function BaseButton({ color, label, variant }: BaseButtonProps) {
let fillColor: string;
let hoverFillColor: string;
let hoverFillOpacity = 1;
let strokeColor: string;
let labelColor: 'primary' | 'secondary';
switch (`${variant}.${color}`) {
case 'contained.primary':
fillColor = theme.colors.primary.background[100];
hoverFillColor = theme.colors.primary.background.hover;
strokeColor = 'none';
labelColor = 'primary';
break;
case 'contained.secondary':
fillColor = theme.colors.secondary.background[100];
hoverFillColor = secondaryContainedHoverFillColor;
strokeColor = 'none';
labelColor = 'secondary';
break;
case 'outlined.primary':
fillColor = 'none';
hoverFillColor = theme.colors.primary.background[100];
strokeColor = theme.colors.primary.background[100];
labelColor = 'secondary';
break;
case 'outlined.secondary':
fillColor = 'none';
hoverFillColor = secondaryOutlinedHoverFillColor;
hoverFillOpacity = secondaryOutlinedHoverFillOpacity;
strokeColor = theme.colors.secondary.background[100];
labelColor = 'primary';
break;
default:
throw new Error(`Unhandled button appearance: ${variant} ${color}`);
@@ -77,8 +122,15 @@ export function BaseButton({ color, label, variant }: BaseButtonProps) {
return (
<>
<ButtonShape fillColor={fillColor} strokeColor={strokeColor} />
<Label data-color={labelColor}>{label}</Label>
<ButtonShape
dataSlot="button-base-shape"
fillColor={fillColor}
strokeColor={strokeColor}
/>
<HoverFill data-slot="button-hover-fill" style={{ opacity: hoverFillOpacity }}>
<ButtonShape fillColor={hoverFillColor} strokeColor="none" />
</HoverFill>
<Label data-slot="button-label">{label}</Label>
</>
);
}
@@ -1,6 +1,7 @@
import { styled } from "@linaria/react";
interface ButtonShapeProps {
dataSlot?: string;
fillColor: string;
strokeColor: string;
}
@@ -38,14 +39,18 @@ const RightCap = styled.svg`
flex-shrink: 0;
`;
export function ButtonShape({ fillColor, strokeColor }: ButtonShapeProps) {
export function ButtonShape({
dataSlot,
fillColor,
strokeColor,
}: ButtonShapeProps) {
const isOutline = fillColor === "none";
return (
<ShapeContainer>
<ShapeContainer data-slot={dataSlot}>
<LeftCap width="4" height="40" viewBox="0 0 4 40" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" />
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
) : (
<path d={LEFT_FILL} fill={fillColor} stroke={strokeColor} />
)}
@@ -32,6 +32,8 @@ export function LinkButton({
if (type === 'anchor') {
return (
<StyledButtonAnchor
data-color={color}
data-variant={variant}
href={href}
rel="noopener noreferrer"
target="_blank"
@@ -42,7 +44,7 @@ export function LinkButton({
}
return (
<StyledButtonLink href={href}>
<StyledButtonLink data-color={color} data-variant={variant} href={href}>
{inner}
</StyledButtonLink>
);
@@ -20,7 +20,12 @@ export function SubmitButton({
variant,
}: SubmitButtonProps) {
return (
<StyledSubmitButton type="submit" onClick={onClick}>
<StyledSubmitButton
data-color={color}
data-variant={variant}
type="submit"
onClick={onClick}
>
<BaseButton color={color} label={label} variant={variant} />
</StyledSubmitButton>
);
@@ -1,6 +1,10 @@
type CheckIconProps = { size: number; color: string };
type CheckIconProps = { size: number; color: string; strokeWidth?: number };
export function CheckIcon({ size, color }: CheckIconProps) {
export function CheckIcon({
size,
color,
strokeWidth = 2,
}: CheckIconProps) {
return (
<svg
width={size}
@@ -13,12 +17,12 @@ export function CheckIcon({ size, color }: CheckIconProps) {
<path
d="m11 6-3.75 4L5 8"
stroke={color}
strokeWidth={2}
strokeWidth={strokeWidth}
/>
<path
d="M8 14a6 6 0 1 0-4.243-1.757"
stroke={color}
strokeWidth={2}
strokeWidth={strokeWidth}
/>
</svg>
);
@@ -1,355 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/pricing/Price/organization.glb';
const scanlineVertexShader = /* glsl */ `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const scanlineFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uStripeScale;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.06);
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
float shadowWeight = mix(1.0, 0.5, ndotl);
float lineWidth = 0.58 * shadowWeight;
float edge = 0.035;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.35);
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
float dashMask = mix(
1.0,
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
highlight
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
if (band < 0.015) {
discard;
}
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
gl_FragColor = vec4(lit, band);
}
`;
function createScanlineMaterial(lightDirection: THREE.Vector3) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 16.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
};
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.planOrganizationMeshRest = rest;
});
}
export function Organization() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const readSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
return {
width: Math.max(nextWidth, 1),
height: Math.max(nextHeight, 1),
};
};
const { width, height } = readSize();
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.35 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const influence = pointer.inside ? 1 : 0.22;
targetRotation.y = pointer.x * 0.52 * influence;
targetRotation.x = pointer.y * 0.42 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
8.2,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
8.2,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.06, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.22);
const my = pointer.y * (pointer.inside ? 1 : 0.22);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.planOrganizationMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 0.55 : 0.22;
sceneObject.position.x =
rest.position.x + mx * 0.12 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.1 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.03 * Math.sin(phase * 2.5);
const twist = (mx * 0.18 + my * 0.14) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const syncCanvasToContainer = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
const resizeObserver = new ResizeObserver(() => {
syncCanvasToContainer();
});
resizeObserver.observe(container);
const handleWindowResize = () => {
syncCanvasToContainer();
};
window.addEventListener('resize', handleWindowResize);
return () => {
cancelled = true;
resizeObserver.disconnect();
window.removeEventListener('resize', handleWindowResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, []);
return (
<div
aria-hidden
ref={mountReference}
style={{
display: 'block',
height: '100%',
minWidth: 0,
width: '100%',
}}
/>
);
}
@@ -1,355 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/pricing/Price/pro.glb';
const scanlineVertexShader = /* glsl */ `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const scanlineFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uStripeScale;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.06);
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
float shadowWeight = mix(1.0, 0.5, ndotl);
float lineWidth = 0.58 * shadowWeight;
float edge = 0.035;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.35);
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
float dashMask = mix(
1.0,
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
highlight
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
if (band < 0.015) {
discard;
}
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
gl_FragColor = vec4(lit, band);
}
`;
function createScanlineMaterial(lightDirection: THREE.Vector3) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 16.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
};
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.planProMeshRest = rest;
});
}
export function Pro() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const readSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
return {
width: Math.max(nextWidth, 1),
height: Math.max(nextHeight, 1),
};
};
const { width, height } = readSize();
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.35 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const influence = pointer.inside ? 1 : 0.22;
targetRotation.y = pointer.x * 0.52 * influence;
targetRotation.x = pointer.y * 0.42 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
8.2,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
8.2,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.06, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.22);
const my = pointer.y * (pointer.inside ? 1 : 0.22);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.planProMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 0.55 : 0.22;
sceneObject.position.x =
rest.position.x + mx * 0.12 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.1 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.03 * Math.sin(phase * 2.5);
const twist = (mx * 0.18 + my * 0.14) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const syncCanvasToContainer = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
const resizeObserver = new ResizeObserver(() => {
syncCanvasToContainer();
});
resizeObserver.observe(container);
const handleWindowResize = () => {
syncCanvasToContainer();
};
window.addEventListener('resize', handleWindowResize);
return () => {
cancelled = true;
resizeObserver.disconnect();
window.removeEventListener('resize', handleWindowResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, []);
return (
<div
aria-hidden
ref={mountReference}
style={{
display: 'block',
height: '100%',
minWidth: 0,
width: '100%',
}}
/>
);
}
@@ -1,366 +0,0 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/partner/three-cards/connect.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capR = dynamicBarHalf * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalf - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float d = sqrt(cdx * cdx + cdy * cdy);
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.partnerCommunityMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
export function Connect() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * influence;
targetRotation.x = pointer.y * 0.62 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
rotationDamp,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
rotationDamp,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
const my = pointer.y * (pointer.inside ? 1 : 0.32);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.partnerCommunityMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 1 : 0.36;
sceneObject.position.x =
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const handleResize = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,366 +0,0 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/partner/three-cards/grow.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capR = dynamicBarHalf * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalf - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float d = sqrt(cdx * cdx + cdy * cdy);
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.partnerSolutionsMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
export function Grow() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * influence;
targetRotation.x = pointer.y * 0.62 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
rotationDamp,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
rotationDamp,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
const my = pointer.y * (pointer.inside ? 1 : 0.32);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.partnerSolutionsMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 1 : 0.36;
sceneObject.position.x =
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const handleResize = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,366 +0,0 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/partner/three-cards/programming.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capR = dynamicBarHalf * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalf - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float d = sqrt(cdx * cdx + cdy * cdy);
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.partnerTechnologyMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
export function Programming() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * influence;
targetRotation.x = pointer.y * 0.62 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
rotationDamp,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
rotationDamp,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
const my = pointer.y * (pointer.inside ? 1 : 0.32);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.partnerTechnologyMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 1 : 0.36;
sceneObject.position.x =
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const handleResize = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
}
@@ -1,5 +1,6 @@
'use client';
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
import {
ILLUSTRATIONS,
type IllustrationId,
@@ -12,5 +13,9 @@ type IllustrationMountProps = {
export function IllustrationMount({ illustration }: IllustrationMountProps) {
const IllustrationComponent = ILLUSTRATIONS[illustration];
return <IllustrationComponent />;
return (
<VisibleWhenTabActive>
<IllustrationComponent />
</VisibleWhenTabActive>
);
}
@@ -8,30 +8,49 @@ import { Spaceship } from './Helped/Spaceship';
import { Target } from './Helped/Target';
import { Product } from './Hero/Product';
import { WhyTwenty } from './Hero/WhyTwenty';
import { Organization } from './Plans/Organization';
import { Pro } from './Plans/Pro';
import { Quotes } from './Quote/Quotes';
import { Hourglass } from './Testimonials/Hourglass';
import { Partner } from './Testimonials/Partner';
import { Connect } from './ThreeCards/Connect';
import { Diamond } from './ThreeCards/Diamond';
import { Eye } from './ThreeCards/Eye';
import { Flash } from './ThreeCards/Flash';
import { Grow } from './ThreeCards/Grow';
import { Lock } from './ThreeCards/Lock';
import { Programming } from './ThreeCards/Programming';
import { PartnerThreeCard } from './ThreeCards/PartnerThreeCard';
import { SingleScreen } from './ThreeCards/SingleScreen';
import { Speed } from './ThreeCards/Speed';
import { Logo as WhyTwentyStepperLogo } from './WhyTwentyStepper/Logo';
const DiamondIllustration = () => (
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/diamond.glb" />
);
const FlashIllustration = () => (
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/flash.glb" />
);
const LockIllustration = () => (
<PartnerThreeCard modelUrl="/illustrations/home/three-cards/lock.glb" />
);
const ConnectIllustration = () => (
<PartnerThreeCard modelUrl="/illustrations/partner/three-cards/connect.glb" />
);
const GrowIllustration = () => (
<PartnerThreeCard modelUrl="/illustrations/partner/three-cards/grow.glb" />
);
const ProgrammingIllustration = () => (
<PartnerThreeCard
modelUrl="/illustrations/partner/three-cards/programming.glb"
/>
);
export const THREE_CARDS_ILLUSTRATIONS = {
diamond: Diamond,
diamond: DiamondIllustration,
eye: Eye,
flash: Flash,
lock: Lock,
connect: Connect,
grow: Grow,
programming: Programming,
flash: FlashIllustration,
lock: LockIllustration,
connect: ConnectIllustration,
grow: GrowIllustration,
programming: ProgrammingIllustration,
singleScreen: SingleScreen,
speed: Speed,
} as const satisfies Record<string, ComponentType>;
@@ -48,8 +67,6 @@ export const ILLUSTRATIONS = {
heroWhyTwenty: WhyTwenty,
testimonialsPartner: Partner,
testimonialsHourglass: Hourglass,
planPro: Pro,
planOrganization: Organization,
money: Money,
spaceship: Spaceship,
target: Target,
@@ -3,7 +3,7 @@ import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode } from 'react';
const ENGAGEMENT_BAND_OVERLAY_SRC =
'/images/pricing/engagement-band/overlay.webp';
@@ -46,6 +46,14 @@ const StyledStrip = styled.div`
row-gap: 0;
}
&[data-has-custom-copy-max-width='true'] {
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-columns:
fit-content(var(--engagement-band-copy-max-width))
minmax(0, 1fr);
}
}
&[data-variant='primary'] {
color: ${theme.colors.primary.text[100]};
}
@@ -57,13 +65,29 @@ const StyledStrip = styled.div`
type StripProps = {
children: ReactNode;
desktopCopyMaxWidth?: string;
fillColor: string;
variant: 'primary' | 'secondary';
};
export function Strip({ children, fillColor, variant }: StripProps) {
export function Strip({
children,
desktopCopyMaxWidth,
fillColor,
variant,
}: StripProps) {
const style = desktopCopyMaxWidth
? ({
'--engagement-band-copy-max-width': desktopCopyMaxWidth,
} as CSSProperties)
: undefined;
return (
<StyledStrip data-variant={variant}>
<StyledStrip
data-has-custom-copy-max-width={Boolean(desktopCopyMaxWidth)}
data-variant={variant}
style={style}
>
<EngagementBandShape fillColor={fillColor} />
<OverlayLayer aria-hidden>
<NextImage
@@ -2,7 +2,7 @@ import {
Body as BaseBody,
type BodyProps,
} from '@/design-system/components/Body/Body';
import { Pages } from '@/enums/pages';
import type { Pages } from '@/enums/pages';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -12,36 +12,36 @@ const BodyContainer = styled.div`
max-width: 360px;
width: 100%;
&[data-page=${Pages.WhyTwenty}] {
&[data-page='whyTwenty'] {
color: ${theme.colors.secondary.text[60]};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page=${Pages.Home}] {
&[data-page='home'] {
max-width: 591px;
}
&[data-page=${Pages.Partner}] {
&[data-page='partner'] {
max-width: 500px;
}
&[data-page=${Pages.Pricing}] {
&[data-page='pricing'] {
max-width: 500px;
}
&[data-page=${Pages.Product}] {
&[data-page='product'] {
max-width: 591px;
}
&[data-page=${Pages.WhyTwenty}] {
&[data-page='whyTwenty'] {
max-width: 443px;
}
&[data-page=${Pages.ReleaseNotes}] {
&[data-page='releaseNotes'] {
max-width: 591px;
}
&[data-page=${Pages.CaseStudies}] {
&[data-page='caseStudies'] {
max-width: 550px;
}
}
@@ -16,8 +16,8 @@ const HALFTONE_CONTRAST = 1;
const HALFTONE_DASH_COLOR = '#959595';
const HALFTONE_HOVER_COLOR = '#4A38F5';
const HALFTONE_HOVER_RADIUS = 0.6;
const HALFTONE_HOVER_POWER_SHIFT = 0.2;
const HALFTONE_HOVER_WIDTH_SHIFT = -0.18;
const HALFTONE_HOVER_POWER_SHIFT = 0.9;
const HALFTONE_HOVER_WIDTH_SHIFT = -0.2;
const HALFTONE_HOVER_LIGHT_INTENSITY = 0;
const HALFTONE_HOVER_LIGHT_RADIUS = 0.2;
@@ -1,3 +1,4 @@
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -22,7 +23,9 @@ const StyledContainer = styled.div`
export function PartnerVisual() {
return (
<StyledContainer>
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
<VisibleWhenTabActive>
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
</VisibleWhenTabActive>
</StyledContainer>
);
}
@@ -1,6 +1,6 @@
'use client';
import { WhyTwenty as WhyTwentyGlb } from '@/illustrations/Hero/WhyTwenty';
import { IllustrationMount } from '@/illustrations';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
@@ -38,7 +38,7 @@ export function WhyTwentyVisual() {
src="/images/why-twenty/hero/background.webp"
/>
</BackgroundLayer>
<WhyTwentyGlb />
<IllustrationMount illustration="heroWhyTwenty" />
</VisualContainer>
);
}
@@ -1,12 +1,17 @@
import { Product as ProductVisual } from '@/illustrations/Hero/Product';
import { IllustrationMount } from '@/illustrations';
import { PartnerVisual } from '@/sections/Hero/components/PartnerVisual/PartnerVisual';
import { WhyTwentyVisual } from '@/sections/Hero/components/WhyTwentyVisual/WhyTwentyVisual';
import { createElement } from 'react';
import { Body } from './Body/Body';
import { Cta } from './Cta/Cta';
import { Heading } from './Heading/Heading';
import { HomeVisual } from './HomeVisual/HomeVisual';
import { Root } from './Root/Root';
function ProductVisual() {
return createElement(IllustrationMount, { illustration: 'heroProduct' });
}
export const Hero = {
Root,
Heading,
@@ -3,13 +3,31 @@
import type { PlansBillingPeriod } from '@/sections/Plans/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useLayoutEffect, useRef, useState } from 'react';
const ToggleTrack = styled.div`
align-items: center;
background-color: ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(20)};
display: flex;
padding: ${theme.spacing(1)};
display: inline-flex;
overflow: hidden;
padding: 2px;
position: relative;
`;
const ToggleHighlight = styled.div`
background-color: ${theme.colors.primary.background[100]};
border-radius: ${theme.radius(8)};
bottom: 2px;
left: 0;
pointer-events: none;
position: absolute;
top: 2px;
transition:
transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1),
width 0.24s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.18s ease;
will-change: transform, width;
`;
const ToggleOption = styled.button`
@@ -23,12 +41,14 @@ const ToggleOption = styled.button`
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
column-gap: ${theme.spacing(2)};
height: ${theme.spacing(6)};
height: 28px;
justify-content: center;
line-height: ${theme.lineHeight(4)};
padding-left: ${theme.spacing(3)};
text-transform: uppercase;
position: relative;
transition: color 0.18s ease;
white-space: nowrap;
z-index: 1;
&[data-period='monthly'] {
padding-right: ${theme.spacing(3)};
@@ -39,7 +59,6 @@ const ToggleOption = styled.button`
}
&[data-active='true'] {
background-color: ${theme.colors.primary.background[100]};
color: ${theme.colors.primary.text[100]};
}
@@ -59,14 +78,14 @@ const DiscountBadge = styled.span`
color: ${theme.colors.secondary.text[100]};
display: inline-flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.regular};
height: ${theme.spacing(5)};
font-size: ${theme.font.size(2.5)};
font-weight: ${theme.font.weight.medium};
height: 24px;
align-items: center;
justify-content: center;
line-height: ${theme.lineHeight(3.5)};
padding-left: ${theme.spacing(1)};
padding-right: ${theme.spacing(1)};
padding-left: ${theme.spacing(1.5)};
padding-right: ${theme.spacing(1.5)};
`;
type BillingToggleProps = {
@@ -74,17 +93,86 @@ type BillingToggleProps = {
onBillingChange: (billing: PlansBillingPeriod) => void;
};
type ToggleHighlightState = {
ready: boolean;
width: number;
x: number;
};
export function BillingToggle({
billing,
onBillingChange,
}: BillingToggleProps) {
const trackRef = useRef<HTMLDivElement>(null);
const monthlyButtonRef = useRef<HTMLButtonElement>(null);
const yearlyButtonRef = useRef<HTMLButtonElement>(null);
const [highlight, setHighlight] = useState<ToggleHighlightState>({
ready: false,
width: 0,
x: 0,
});
useLayoutEffect(() => {
const track = trackRef.current;
const monthlyButton = monthlyButtonRef.current;
const yearlyButton = yearlyButtonRef.current;
if (!track || !monthlyButton || !yearlyButton) {
return;
}
const syncHighlight = () => {
const activeButton =
billing === 'monthly' ? monthlyButton : yearlyButton;
const trackRect = track.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
const nextHighlight = {
ready: true,
width: buttonRect.width,
x: buttonRect.left - trackRect.left,
};
setHighlight((currentHighlight) => {
if (
currentHighlight.ready === nextHighlight.ready &&
currentHighlight.width === nextHighlight.width &&
currentHighlight.x === nextHighlight.x
) {
return currentHighlight;
}
return nextHighlight;
});
};
syncHighlight();
const resizeObserver = new ResizeObserver(syncHighlight);
resizeObserver.observe(track);
resizeObserver.observe(monthlyButton);
resizeObserver.observe(yearlyButton);
return () => {
resizeObserver.disconnect();
};
}, [billing]);
return (
<ToggleTrack role="radiogroup" aria-label="Billing period">
<ToggleTrack ref={trackRef} role="radiogroup" aria-label="Billing period">
<ToggleHighlight
aria-hidden="true"
style={{
opacity: highlight.ready ? 1 : 0,
transform: `translateX(${highlight.x}px)`,
width: `${highlight.width}px`,
}}
/>
<ToggleOption
aria-checked={billing === 'monthly'}
data-active={billing === 'monthly'}
data-period="monthly"
onClick={() => onBillingChange('monthly')}
ref={monthlyButtonRef}
role="radio"
type="button"
>
@@ -95,6 +183,7 @@ export function BillingToggle({
data-active={billing === 'yearly'}
data-period="yearly"
onClick={() => onBillingChange('yearly')}
ref={yearlyButtonRef}
role="radio"
type="button"
>
@@ -1,13 +1,14 @@
'use client';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { Body, Heading, LinkButton } from '@/design-system/components';
import { CheckIcon } from '@/icons/informative/Check';
import { IllustrationMount } from '@/illustrations';
import type { PlanCardType } from '@/sections/Plans/types';
import { theme } from '@/theme';
import { css } from '@linaria/core';
const FIXED_ROWS = 4;
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const StyledCard = styled.div`
background-color: ${theme.colors.primary.background[100]};
@@ -15,7 +16,6 @@ const StyledCard = styled.div`
border-radius: ${theme.radius(2)};
display: grid;
grid-template-columns: 1fr;
grid-template-rows: subgrid;
overflow: hidden;
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(4)};
@@ -27,14 +27,15 @@ const StyledCard = styled.div`
`;
const CardHeader = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
flex-direction: row;
gap: ${theme.spacing(3)};
justify-content: space-between;
overflow: visible;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: flex-start;
flex-direction: row;
justify-content: space-between;
gap: ${theme.spacing(4)};
}
`;
@@ -48,6 +49,10 @@ const CardHeaderInfo = styled.div`
`;
const cardPlanTitleClassName = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&[data-size='xs'] {
line-height: ${theme.lineHeight(5)};
}
@@ -61,29 +66,27 @@ const cardPlanTitleClassName = css`
const priceBodyClassName = css`
color: ${theme.colors.primary.text[60]};
display: block;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
const PriceLine = styled.div`
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(1)};
white-space: nowrap;
`;
const CardIllustrationEmbed = styled.div`
background-color: ${theme.colors.primary.background[100]};
border: none;
border-radius: ${theme.radius(2)};
display: block;
flex-shrink: 0;
height: 80px;
overflow: hidden;
width: 197px;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
margin-left: auto;
transform: translateX(${theme.spacing(4)});
flex-wrap: nowrap;
overflow: hidden;
white-space: nowrap;
}
`;
@@ -93,6 +96,32 @@ const CardRule = styled.div`
width: 100%;
`;
const CardIcon = styled.div`
--card-icon-width: 80px;
background-color: ${theme.colors.primary.background[100]};
border: none;
border-radius: ${theme.radius(2)};
display: block;
flex-shrink: 0;
height: 80px;
margin-left: auto;
overflow: hidden;
position: relative;
width: var(--card-icon-width);
img {
object-fit: contain;
object-position: center right;
}
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
margin-left: auto;
height: 80px;
width: var(--card-icon-width);
}
`;
const CtaWrapper = styled.div`
width: 100%;
@@ -102,23 +131,95 @@ const CtaWrapper = styled.div`
}
`;
const FEATURES_SWITCH_ANIMATION_MS = 110;
const FEATURE_ITEM_STAGGER_MS = 8;
const FEATURE_ITEM_EXPANDED_HEIGHT = theme.spacing(8);
const FEATURE_ITEM_SPACING = theme.spacing(4);
const FeaturesViewport = styled.div`
height: var(--features-height, auto);
min-width: 0;
overflow: hidden;
position: relative;
transition: height ${FEATURES_SWITCH_ANIMATION_MS}ms
cubic-bezier(0.2, 0.8, 0.2, 1);
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
const FeaturesList = styled.ul`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: subgrid;
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
min-width: 0;
padding: 0;
width: 100%;
`;
const FeatureCheck = styled.span`
align-items: center;
display: inline-flex;
height: 16px;
justify-content: center;
width: 16px;
`;
const FeatureItem = styled.li`
@keyframes pricingFeatureItemEnter {
from {
margin-top: 0;
max-height: 0;
opacity: 0;
}
to {
margin-top: var(--feature-spacing, 0px);
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
opacity: 1;
}
}
@keyframes pricingFeatureItemExit {
from {
margin-top: var(--feature-spacing, 0px);
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
opacity: 1;
}
to {
margin-top: 0;
max-height: 0;
opacity: 0;
}
}
align-items: center;
column-gap: ${theme.spacing(2)};
display: grid;
grid-template-columns: auto 1fr;
margin-top: var(--feature-spacing, 0px);
max-height: var(--feature-item-height, ${FEATURE_ITEM_EXPANDED_HEIGHT});
min-width: 0;
overflow: hidden;
&[data-state='entering'] {
animation: pricingFeatureItemEnter ${FEATURES_SWITCH_ANIMATION_MS}ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--feature-index) * ${FEATURE_ITEM_STAGGER_MS}ms);
}
&[data-state='exiting'] {
animation: pricingFeatureItemExit ${FEATURES_SWITCH_ANIMATION_MS}ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--feature-index) * ${FEATURE_ITEM_STAGGER_MS}ms);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
type CardProps = {
@@ -127,11 +228,191 @@ type CardProps = {
maxBullets: number;
};
const PRICE_ROLL_DURATION_MS = 500;
const PRICE_NUMBER_FORMATTER = new Intl.NumberFormat('en-US');
const PRICE_HEADING_NUMBER_REGEX = /^(.*?)(\d[\d,]*)(.*)$/;
const FEATURE_LIST_ROW_MIN_HEIGHT = theme.spacing(5.5);
const useAnimatedNumber = (target: number) => {
const [display, setDisplay] = useState(target);
const previousValueRef = useRef(target);
useEffect(() => {
const from = previousValueRef.current;
previousValueRef.current = target;
if (from === target) {
return;
}
const start = performance.now();
let animationFrameId = 0;
const tick = (now: number) => {
const progress = Math.min((now - start) / PRICE_ROLL_DURATION_MS, 1);
const eased = 1 - (1 - progress) ** 3;
setDisplay(Math.round(from + (target - from) * eased));
if (progress < 1) {
animationFrameId = requestAnimationFrame(tick);
}
};
animationFrameId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [target]);
return display;
};
function getHeadingSegments(heading: PlanCardType['price']['heading']) {
return Array.isArray(heading) ? heading : [heading];
}
function getPriceHeadingNumericValue(heading: PlanCardType['price']['heading']) {
const segments = getHeadingSegments(heading);
for (const segment of segments) {
const match = segment.text.match(PRICE_HEADING_NUMBER_REGEX);
if (!match) {
continue;
}
return Number(match[2].replaceAll(',', ''));
}
return null;
}
function getAnimatedPriceHeading(
heading: PlanCardType['price']['heading'],
animatedValue: number,
) {
const originalIsArray = Array.isArray(heading);
let replaced = false;
const nextSegments = getHeadingSegments(heading).map((segment) => {
if (replaced) {
return segment;
}
const match = segment.text.match(PRICE_HEADING_NUMBER_REGEX);
if (!match) {
return segment;
}
replaced = true;
return {
...segment,
text: `${match[1]}${PRICE_NUMBER_FORMATTER.format(animatedValue)}${match[3]}`,
};
});
return originalIsArray ? nextSegments : nextSegments[0];
}
function getBulletsKey(bullets: PlanCardType['features']['bullets']) {
return bullets.map((bullet) => bullet.text).join('||');
}
function getFeaturesMinHeight(maxBullets: number) {
if (maxBullets <= 0) {
return '0px';
}
return `calc((${FEATURE_LIST_ROW_MIN_HEIGHT} * ${maxBullets}) + (${theme.spacing(
4,
)} * ${maxBullets - 1}))`;
}
export function Card({ card, highlighted = false, maxBullets }: CardProps) {
const totalRows = FIXED_ROWS + maxBullets;
const iconWidth = card.icon.width ?? 80;
const iconStyle = {
'--card-icon-width': `${iconWidth}px`,
} as CSSProperties;
const targetPriceValue = getPriceHeadingNumericValue(card.price.heading);
const animatedPriceValue = useAnimatedNumber(targetPriceValue ?? 0);
const animatedPriceHeading =
targetPriceValue === null
? card.price.heading
: getAnimatedPriceHeading(card.price.heading, animatedPriceValue);
const [visibleBullets, setVisibleBullets] = useState(card.features.bullets);
const [queuedBullets, setQueuedBullets] = useState<
PlanCardType['features']['bullets'] | null
>(null);
const [comparisonBullets, setComparisonBullets] = useState<
PlanCardType['features']['bullets'] | null
>(null);
const [featuresPhase, setFeaturesPhase] = useState<
'stable' | 'exiting' | 'entering'
>('stable');
useEffect(() => {
const nextBullets = card.features.bullets;
const nextBulletsKey = getBulletsKey(nextBullets);
if (
nextBulletsKey === getBulletsKey(visibleBullets) ||
nextBulletsKey === getBulletsKey(queuedBullets ?? [])
) {
return;
}
setQueuedBullets(nextBullets);
if (featuresPhase === 'stable') {
setFeaturesPhase('exiting');
}
}, [card.features.bullets, featuresPhase, queuedBullets, visibleBullets]);
useEffect(() => {
if (featuresPhase !== 'exiting' || !queuedBullets) {
return;
}
const timeoutId = window.setTimeout(() => {
setComparisonBullets(visibleBullets);
setVisibleBullets(queuedBullets);
setQueuedBullets(null);
setFeaturesPhase('entering');
}, FEATURES_SWITCH_ANIMATION_MS +
FEATURE_ITEM_STAGGER_MS * visibleBullets.length);
return () => {
window.clearTimeout(timeoutId);
};
}, [featuresPhase, queuedBullets, visibleBullets]);
useEffect(() => {
if (featuresPhase !== 'entering') {
return;
}
const timeoutId = window.setTimeout(() => {
setComparisonBullets(null);
setFeaturesPhase('stable');
}, FEATURES_SWITCH_ANIMATION_MS +
FEATURE_ITEM_STAGGER_MS * visibleBullets.length);
return () => {
window.clearTimeout(timeoutId);
};
}, [featuresPhase, visibleBullets]);
const comparisonBulletTexts = new Set(
(featuresPhase === 'exiting' ? queuedBullets : comparisonBullets)?.map(
(bullet) => bullet.text,
) ?? [],
);
return (
<StyledCard style={{ gridRow: `span ${totalRows}` }}>
<StyledCard>
<CardHeader>
<CardHeaderInfo>
<Heading
@@ -144,7 +425,7 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
<PriceLine>
<Heading
as="h4"
segments={card.price.heading}
segments={animatedPriceHeading}
size="sm"
weight="regular"
/>
@@ -156,11 +437,56 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
/>
</PriceLine>
</CardHeaderInfo>
<CardIllustrationEmbed>
<IllustrationMount illustration={card.illustration} />
</CardIllustrationEmbed>
<CardIcon style={iconStyle}>
<NextImage
alt={card.icon.alt}
fill
sizes={`${iconWidth}px`}
src={card.icon.src}
/>
</CardIcon>
</CardHeader>
<CardRule />
<FeaturesViewport
style={{
'--features-height': getFeaturesMinHeight(maxBullets),
} as CSSProperties}
>
<FeaturesList data-state={featuresPhase}>
{visibleBullets.map((bullet, index) => (
<FeatureItem
data-state={
featuresPhase === 'stable'
? 'stable'
: comparisonBulletTexts.has(bullet.text)
? 'stable'
: featuresPhase
}
key={bullet.text}
style={
{
'--feature-index': index,
'--feature-item-height': FEATURE_ITEM_EXPANDED_HEIGHT,
'--feature-spacing':
index > 0 ? FEATURE_ITEM_SPACING : '0px',
} as CSSProperties
}
>
<FeatureCheck>
<CheckIcon
color={theme.colors.highlight[100]}
size={16}
strokeWidth={1.5}
/>
</FeatureCheck>
<Body as="span" body={bullet} size="sm" />
</FeatureItem>
))}
</FeaturesList>
</FeaturesViewport>
<CtaWrapper>
<LinkButton
color="secondary"
@@ -170,19 +496,6 @@ export function Card({ card, highlighted = false, maxBullets }: CardProps) {
variant={highlighted ? 'contained' : 'outlined'}
/>
</CtaWrapper>
<CardRule />
<Body body={card.features.title} size="md" />
<FeaturesList style={{ gridRow: `span ${maxBullets}` }}>
{card.features.bullets.map((bullet, index) => (
<FeatureItem key={index}>
<CheckIcon color={theme.colors.highlight[100]} size={16} />
<Body as="span" body={bullet} size="sm" />
</FeatureItem>
))}
</FeaturesList>
</StyledCard>
);
}
@@ -1,16 +1,18 @@
'use client';
import type { PlanCardType } from '@/sections/Plans/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { Card } from '../Card/Card';
const CardsGrid = styled.div`
column-gap: ${theme.spacing(4)};
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(4)};
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
column-gap: ${theme.spacing(6)};
grid-template-columns: 1fr 1fr;
}
`;
@@ -20,9 +20,9 @@ const Checkbox = styled.span`
border: 1px solid ${theme.colors.highlight[100]};
border-radius: ${theme.radius(1)};
display: grid;
height: 20px;
height: 16px;
justify-items: center;
width: 20px;
width: 16px;
&[data-checked='true'] {
background-color: ${theme.colors.highlight[100]};
@@ -38,9 +38,9 @@ function CheckmarkIcon() {
<svg
aria-hidden
fill="none"
height="12"
height="10"
viewBox="0 0 12 12"
width="12"
width="10"
xmlns="http://www.w3.org/2000/svg"
>
<path
@@ -84,9 +84,7 @@ export function SelfHostToggle({
<ToggleRow>
<HiddenInput
checked={isSelfHost}
onChange={() =>
onHostingChange(isSelfHost ? 'cloud' : 'selfHost')
}
onChange={() => onHostingChange(isSelfHost ? 'cloud' : 'selfHost')}
type="checkbox"
/>
<LabelText>Selfhosting</LabelText>
@@ -1,11 +1,15 @@
import { type HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { IllustrationId } from '@/illustrations';
import type { ImageType } from '@/design-system/components/Image/types/Image';
import { type PlanFeaturesType } from '@/sections/Plans/types/PlanFeatures';
import { type PlanPriceType } from '@/sections/Plans/types/PlanPrice';
export type PlanIconType = ImageType & {
width?: number;
};
export type PlanCardType = {
heading: HeadingType;
price: PlanPriceType;
illustration: IllustrationId;
icon: PlanIconType;
features: PlanFeaturesType;
};
@@ -1,6 +1,5 @@
import { BodyType } from '@/design-system/components/Body/types/Body';
export type PlanFeaturesType = {
title: BodyType;
bullets: BodyType[];
};
@@ -1,6 +1,6 @@
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { IllustrationId } from '@/illustrations';
import type { PlanIconType } from './PlanCard';
import type { PlanPriceType } from './PlanPrice';
// Same literals as UI toggles when reading cells.
@@ -27,9 +27,8 @@ export type PlansTierCellsType = {
// Shared across all eight combinations for this tier.
export type PlansTierType = {
cells: PlansTierCellsType;
featuresTitle: BodyType;
heading: HeadingType;
illustration: IllustrationId;
icon: PlanIconType;
};
export type PlansDataType = {
@@ -17,11 +17,10 @@ export function getPlanCard(
return {
heading: tier.heading,
illustration: tier.illustration,
icon: tier.icon,
price: cell.price,
features: {
bullets: cell.featureBullets,
title: tier.featuresTitle,
},
};
}
@@ -1,3 +1,4 @@
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Monolith from './monolith';
@@ -53,7 +54,9 @@ export function Visual() {
<StyledVisual>
<StyledMasked>
<StyledHalftoneLayer>
<Monolith />
<VisibleWhenTabActive>
<Monolith />
</VisibleWhenTabActive>
</StyledHalftoneLayer>
</StyledMasked>
</StyledVisual>
@@ -114,13 +114,20 @@ const PromoTagBorder = styled.div`
clip-path: ${STARBURST_CLIP};
background: #005fb2;
filter: drop-shadow(2px 3px 6px rgba(0, 40, 80, 0.45));
height: 130px;
display: inline-flex;
min-width: 200px;
padding: 4px;
position: absolute;
right: ${theme.spacing(25)};
top: -16px;
transform: rotate(-8deg);
width: 200px;
z-index: 30;
@media (max-width: ${theme.breakpoints.md - 1}px) {
min-width: 168px;
right: ${theme.spacing(8)};
top: -8px;
}
`;
const PromoTagInner = styled.div`
@@ -132,17 +139,20 @@ const PromoTagInner = styled.div`
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4.5)};
font-weight: bold;
height: calc(100% - 8px);
justify-content: center;
left: 4px;
letter-spacing: 1px;
line-height: 1.3;
position: absolute;
line-height: 1.15;
min-height: 104px;
padding: ${theme.spacing(7)} ${theme.spacing(10)};
text-align: center;
text-transform: uppercase;
top: 4px;
white-space: pre-line;
width: calc(100% - 8px);
@media (max-width: ${theme.breakpoints.md - 1}px) {
font-size: ${theme.font.size(4)};
min-height: 88px;
padding: ${theme.spacing(6)} ${theme.spacing(7)};
}
`;
const Panel = styled.div`
@@ -654,131 +664,128 @@ export function PricingWindow({
<Panel>
<WindowChrome aria-hidden="true" />
<PricingHeader>
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
<TitleBarActions>
<TitleBarActionButton
aria-label="Help"
onClick={() => undefined}
type="button"
>
?
</TitleBarActionButton>
<TitleBarActionButton
aria-label="Close pricing window"
onClick={onClose}
type="button"
>
×
</TitleBarActionButton>
</TitleBarActions>
</TitleBar>
<SummaryPad>
<SummaryInner>
<ProductBlock>
<ProductHeader>
<ProductCopy>
<ProductTitle>{pricing.productTitle}</ProductTitle>
<PriceRow>
{perSeatPriceAmount > pricing.basePriceAmount ? (
<BasePriceAmount>
{formatPriceAmount(pricing.basePriceAmount)}
</BasePriceAmount>
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
<TitleBarActions>
<TitleBarActionButton
aria-label="Help"
onClick={() => undefined}
type="button"
>
?
</TitleBarActionButton>
<TitleBarActionButton
aria-label="Close pricing window"
onClick={onClose}
type="button"
>
×
</TitleBarActionButton>
</TitleBarActions>
</TitleBar>
<SummaryPad>
<SummaryInner>
<ProductBlock>
<ProductHeader>
<ProductCopy>
<ProductTitle>{pricing.productTitle}</ProductTitle>
<PriceRow>
{perSeatPriceAmount > pricing.basePriceAmount ? (
<BasePriceAmount>
{formatPriceAmount(pricing.basePriceAmount)}
</BasePriceAmount>
) : null}
<PriceAmount>
{formatPriceAmount(animatedPerSeat)}
</PriceAmount>
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
</PriceRow>
{fixedPriceAmount > 0 ? (
<TotalPriceRow>
<TotalPriceAmount>
{formatPriceAmount(animatedTotal)}
</TotalPriceAmount>
<TotalPriceLabel>
{pricing.totalPriceLabel}
</TotalPriceLabel>
</TotalPriceRow>
) : null}
<PriceAmount>
{formatPriceAmount(animatedPerSeat)}
</PriceAmount>
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
</PriceRow>
{fixedPriceAmount > 0 ? (
<TotalPriceRow>
<TotalPriceAmount>
{formatPriceAmount(animatedTotal)}
</TotalPriceAmount>
<TotalPriceLabel>
{pricing.totalPriceLabel}
</TotalPriceLabel>
</TotalPriceRow>
) : null}
</ProductCopy>
<ProductIcon
alt={pricing.productIconAlt}
src={pricing.productIconSrc}
/>
</ProductHeader>
</ProductBlock>
<Separator aria-hidden="true" />
</SummaryInner>
</SummaryPad>
</PricingHeader>
<ContentPad>
<Inner>
<SectionHeader>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
<SelectAllButton
onClick={onSelectAll}
type="button"
>
Select all
</SelectAllButton>
</SectionHeader>
{pricing.addons.map((addon) => {
const checked = checkedIds.has(addon.id);
return (
<AddonRow key={addon.id}>
<CheckboxLabel
disabled={addon.disabled}
ref={(node) => {
addonAnchorRefs.current[addon.id] = node;
}}
>
<HiddenCheckbox
checked={checked}
disabled={addon.disabled}
onChange={() =>
onAddonToggle(
addon,
addonAnchorRefs.current[
addon.id
]?.getBoundingClientRect() ?? null,
)
}
type="checkbox"
</ProductCopy>
<ProductIcon
alt={pricing.productIconAlt}
src={pricing.productIconSrc}
/>
<CheckboxFace checked={checked} aria-hidden="true">
{checked ? <CheckGlyph></CheckGlyph> : null}
</CheckboxFace>
<AddonLabelText>{addon.label}</AddonLabelText>
</CheckboxLabel>
<AddonRightText>
{addon.rightLabelParts
? renderRightLabelParts(addon.rightLabelParts)
: renderRightLabel(addon.rightLabel)}
</AddonRightText>
{addon.tooltip ? (
<Tooltip>
<TooltipTitleBar>{addon.tooltip.title}</TooltipTitleBar>
<TooltipBody>{addon.tooltip.body}</TooltipBody>
</Tooltip>
) : null}
</AddonRow>
);
})}
<FooterCtaSection>
<Separator aria-hidden="true" />
{pricing.secondaryCtaNote ? (
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
) : null}
<FakeButton
href={pricing.secondaryCtaHref}
rel="noreferrer"
target="_blank"
>
{pricing.secondaryCtaLabel}
</FakeButton>
</FooterCtaSection>
</Inner>
</ContentPad>
</ProductHeader>
</ProductBlock>
<Separator aria-hidden="true" />
</SummaryInner>
</SummaryPad>
</PricingHeader>
<ContentPad>
<Inner>
<SectionHeader>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
<SelectAllButton onClick={onSelectAll} type="button">
Select all
</SelectAllButton>
</SectionHeader>
{pricing.addons.map((addon) => {
const checked = checkedIds.has(addon.id);
return (
<AddonRow key={addon.id}>
<CheckboxLabel
disabled={addon.disabled}
ref={(node) => {
addonAnchorRefs.current[addon.id] = node;
}}
>
<HiddenCheckbox
checked={checked}
disabled={addon.disabled}
onChange={() =>
onAddonToggle(
addon,
addonAnchorRefs.current[
addon.id
]?.getBoundingClientRect() ?? null,
)
}
type="checkbox"
/>
<CheckboxFace checked={checked} aria-hidden="true">
{checked ? <CheckGlyph></CheckGlyph> : null}
</CheckboxFace>
<AddonLabelText>{addon.label}</AddonLabelText>
</CheckboxLabel>
<AddonRightText>
{addon.rightLabelParts
? renderRightLabelParts(addon.rightLabelParts)
: renderRightLabel(addon.rightLabel)}
</AddonRightText>
{addon.tooltip ? (
<Tooltip>
<TooltipTitleBar>{addon.tooltip.title}</TooltipTitleBar>
<TooltipBody>{addon.tooltip.body}</TooltipBody>
</Tooltip>
) : null}
</AddonRow>
);
})}
<FooterCtaSection>
<Separator aria-hidden="true" />
{pricing.secondaryCtaNote ? (
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
) : null}
<FakeButton
href={pricing.secondaryCtaHref}
rel="noreferrer"
target="_blank"
>
{pricing.secondaryCtaLabel}
</FakeButton>
</FooterCtaSection>
</Inner>
</ContentPad>
</Panel>
</PanelWrapper>
);
@@ -162,6 +162,8 @@ export function Carousel({ children, eyebrow, testimonials }: CarouselProps) {
const hasPrevious = index > 0;
const hasNext = index < total - 1;
const current = testimonials[index];
const authorSecondaryLine =
current.author.designation ?? current.author.handle ?? null;
const goToPrevious = () => {
if (hasPrevious) setIndex(index - 1);
@@ -246,12 +248,14 @@ export function Carousel({ children, eyebrow, testimonials }: CarouselProps) {
size="sm"
weight="medium"
/>
<Body
as="span"
body={current.author.designation}
size="xs"
weight="light"
/>
{authorSecondaryLine ? (
<Body
as="span"
body={authorSecondaryLine}
size="xs"
weight="light"
/>
) : null}
</AuthorBlock>
</FooterRow>
</RightColumn>
@@ -2,7 +2,7 @@
import { Body, Heading, IconButton } from '@/design-system/components';
import { ArrowRightIcon } from '@/icons';
import { THREE_CARDS_ILLUSTRATIONS } from '@/illustrations';
import { IllustrationMount } from '@/illustrations';
import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -79,9 +79,6 @@ export function IllustrationCard({
illustrationCard,
variant = 'shaped',
}: IllustrationCardProps) {
const ThreeCardsIllustration =
THREE_CARDS_ILLUSTRATIONS[illustrationCard.illustration];
return (
<IllustrationCardContainer>
{variant === 'shaped' && (
@@ -98,7 +95,7 @@ export function IllustrationCard({
/>
<CardRule />
<CardEmbed>
<ThreeCardsIllustration />
<IllustrationMount illustration={illustrationCard.illustration} />
</CardEmbed>
<CardRule />
<CardBodyCell>
@@ -1,6 +1,9 @@
export const colors = {
primary: {
background: { 100: 'var(--color-white-100)' },
background: {
100: 'var(--color-white-100)',
hover: 'var(--color-black-100)',
},
text: {
100: 'var(--color-black-100)',
80: 'var(--color-black-80)',
@@ -23,6 +26,7 @@ export const colors = {
secondary: {
background: {
100: 'var(--color-black-100)',
hover: 'var(--color-black-hover)',
80: 'var(--color-black-80)',
5: 'var(--color-black-5)',
},
@@ -10,6 +10,7 @@ export const cssVariables = css`
--color-white-10: #FFFFFF1A;
--color-black-100: #1C1C1C;
--color-black-hover: #333333;
--color-black-80: #1C1C1CCC;
--color-black-60: #1C1C1C99;
--color-black-40: #1C1C1C66;