Fixes on website (#19625)

## Summary
- fix the halftone studio image-switch behavior so image mode uses a
sane default preview distance instead of rendering nearly off-screen
- add shared preview-distance handling for shape and image modes, and
tune the default 3D idle auto-rotate speed
- update halftone controls/export plumbing to support the latest studio
settings changes
- refresh website UI/content in pricing, Salesforce, menu, and
billing-related sections

## Testing
- Ran targeted Jest tests for halftone state and footprint logic
- Ran TypeScript check for `packages/twenty-website-new`
- Broader app-level/manual testing not run
This commit is contained in:
Thomas des Francs
2026-04-13 15:55:38 +02:00
committed by GitHub
parent c67602f2e8
commit 87bb2f94bb
16 changed files with 609 additions and 66 deletions
@@ -26,6 +26,7 @@ type ControlsPanelProps = {
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
onCopyShareLink: () => void;
onDashColorChange: (value: string) => void;
onHoverDashColorChange: (value: string) => void;
onExportHalftoneImage: (width: number, height: number) => void;
onExportBackgroundChange: (value: boolean) => void;
onExportHtml: () => void;
@@ -121,6 +122,7 @@ export function ControlsPanel({
onBackgroundChange,
onCopyShareLink,
onDashColorChange,
onHoverDashColorChange,
onExportHalftoneImage,
onExportBackgroundChange,
onExportHtml,
@@ -207,6 +209,7 @@ export function ControlsPanel({
{visible && activeTab === 'animations' ? (
<AnimationsTab
onAnimationSettingsChange={onAnimationSettingsChange}
onHoverDashColorChange={onHoverDashColorChange}
settings={settings}
/>
) : null}
@@ -112,6 +112,7 @@ const halftoneFragmentShader = `
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@@ -119,6 +120,7 @@ const halftoneFragmentShader = `
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverHalftoneActive;
uniform float hoverHalftonePowerShift;
uniform float hoverHalftoneRadius;
uniform float hoverHalftoneWidthShift;
@@ -178,10 +180,7 @@ const halftoneFragmentShader = `
}
float hoverHalftoneMask = 0.0;
if (
abs(hoverHalftonePowerShift) > 0.0001 ||
abs(hoverHalftoneWidthShift) > 0.0001
) {
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
}
@@ -249,7 +248,8 @@ const halftoneFragmentShader = `
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
vec3 color = dashColor * alpha;
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
@@ -491,6 +491,9 @@ function updateHalftone(
(resources.halftoneMaterial.uniforms.dashColor.value as THREE.Color).set(
settings.halftone.dashColor,
);
(resources.halftoneMaterial.uniforms.hoverDashColor.value as THREE.Color).set(
settings.halftone.hoverDashColor,
);
resources.halftoneMaterial.uniforms.waveAmount.value =
settings.animation.waveEnabled && settings.sourceMode !== 'image'
? settings.animation.waveAmount
@@ -665,6 +668,11 @@ export function HalftoneCanvas({
}
const texture = new THREE.Texture(imageElement);
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
texture.colorSpace = THREE.SRGBColorSpace;
resources.imageTexture = texture;
@@ -860,6 +868,9 @@ export function HalftoneCanvas({
dashColor: {
value: new THREE.Color(initialSettings.halftone.dashColor),
},
hoverDashColor: {
value: new THREE.Color(initialSettings.halftone.hoverDashColor),
},
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: 1 },
@@ -867,6 +878,7 @@ export function HalftoneCanvas({
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
dragOffset: { value: new THREE.Vector2(0, 0) },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: { value: 0 },
hoverHalftoneRadius: { value: 0.2 },
hoverHalftoneWidthShift: { value: 0 },
@@ -1054,6 +1066,7 @@ export function HalftoneCanvas({
snapshotWidth,
snapshotHeight,
);
halftoneMaterial.uniforms.hoverHalftoneActive.value = 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value = 0;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = 0;
halftoneMaterial.uniforms.hoverLightStrength.value = 0;
@@ -1577,6 +1590,10 @@ export function HalftoneCanvas({
-interaction.pointerVelocityY * logicalHeight,
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
pointerActive && activeSettings.animation.hoverHalftoneEnabled
? 1
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointerActive && activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftonePowerShift
@@ -1902,6 +1919,7 @@ export function HalftoneCanvas({
transmissionBacksideTarget,
transmissionTarget,
});
halftoneMaterial.uniforms.hoverHalftoneActive.value = 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value = 0;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = 0;
halftoneMaterial.uniforms.hoverLightStrength.value = 0;
@@ -167,6 +167,7 @@ const HiddenFileInput = styled.input`
display: none;
`;
const DEFAULT_PREVIEW_DISTANCE = 6;
const DEFAULT_IMAGE_ASSET_PATH = '/images/shared/halftone/twenty-logo.svg';
const DEFAULT_IMAGE_FILENAME = 'twenty-logo.svg';
type PendingFilePicker = {
@@ -282,7 +283,9 @@ export function HalftoneStudio() {
createInitialHalftoneStudioState,
);
const [controlsVisible, setControlsVisible] = useState(true);
const [previewDistance, setPreviewDistance] = useState(7);
const [previewDistance, setPreviewDistance] = useState(
DEFAULT_PREVIEW_DISTANCE,
);
const [activeGeometry, setActiveGeometry] = useState<THREE.BufferGeometry>(
() => createFallbackGeometry(),
);
@@ -566,6 +569,7 @@ export function HalftoneStudio() {
const activateUploadedModel = useCallback(
(file: File) => {
const currentDashColor = state.settings.halftone.dashColor;
const currentHoverDashColor = state.settings.halftone.hoverDashColor;
const extension = file.name.split('.').pop()?.toLowerCase();
const loader = extension === 'glb' ? 'glb' : 'fbx';
const nextShape: HalftoneGeometrySpec = {
@@ -594,6 +598,7 @@ export function HalftoneStudio() {
value: {
...halftoneBySourceModeReference.current.shape,
dashColor: currentDashColor,
hoverDashColor: currentHoverDashColor,
},
});
},
@@ -603,6 +608,7 @@ export function HalftoneStudio() {
const activateUploadedImage = useCallback(
(file: File) => {
const currentDashColor = state.settings.halftone.dashColor;
const currentHoverDashColor = state.settings.halftone.hoverDashColor;
setImageFile(file);
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
...state.settings.halftone,
@@ -613,6 +619,7 @@ export function HalftoneStudio() {
value: {
...halftoneBySourceModeReference.current.image,
dashColor: currentDashColor,
hoverDashColor: currentHoverDashColor,
},
});
},
@@ -788,7 +795,9 @@ export function HalftoneStudio() {
exportPoseReference.current = preset.initialPose;
setCanvasInitialPose(preset.initialPose);
setPreviewDistance(preset.previewDistance ?? REFERENCE_PREVIEW_DISTANCE);
const nextPreviewDistance =
preset.previewDistance ?? REFERENCE_PREVIEW_DISTANCE;
setPreviewDistance(nextPreviewDistance);
setExportName(
preset.componentName ?? presetFile.name.replace(/\.(tsx|html)$/i, ''),
);
@@ -1100,6 +1109,12 @@ export function HalftoneStudio() {
value: { dashColor: value },
})
}
onHoverDashColorChange={(value) =>
dispatch({
type: 'patchHalftone',
value: { hoverDashColor: value },
})
}
onExportHalftoneImage={(width, height) => {
void handleExportHalftoneImage(width, height);
}}
@@ -7,6 +7,9 @@ import {
} from '@/app/halftone/_lib/formatters';
import type { HalftoneStudioSettings } from '@/app/halftone/_lib/state';
import {
ColorControlLabel,
ColorControlRow,
ColorField,
ControlGrid,
LabelWithTooltip,
Section,
@@ -25,6 +28,7 @@ type AnimationsTabProps = {
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
) => void;
onHoverDashColorChange: (value: string) => void;
settings: HalftoneStudioSettings;
};
@@ -34,6 +38,7 @@ function effectLabel(label: string, description: string) {
export function AnimationsTab({
onAnimationSettingsChange,
onHoverDashColorChange,
settings,
}: AnimationsTabProps) {
const animation = settings.animation;
@@ -108,6 +113,14 @@ export function AnimationsTab({
>
Radius
</SliderControl>
<ColorControlRow>
<ColorControlLabel>Hover color</ColorControlLabel>
<ColorField
ariaLabel="Hover dash color"
onChange={onHoverDashColorChange}
value={settings.halftone.hoverDashColor}
/>
</ColorControlRow>
</ControlGrid>
) : null}
</Section>
@@ -176,6 +176,9 @@ export function ExportTab({
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Dash color: ${settings.halftone.dashColor}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Hover color: ${settings.halftone.hoverDashColor}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Scale: ${settings.halftone.scale.toFixed(2)}`}
</div>
@@ -1,9 +1,13 @@
import {
generateReactComponent,
parseExportedPreset,
generateStandaloneHtml,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { DEFAULT_HALFTONE_SETTINGS } from '@/app/halftone/_lib/state';
import {
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
describe('halftone export naming', () => {
it('normalizes free-form export names into safe component and file names', () => {
@@ -34,4 +38,24 @@ describe('halftone export naming', () => {
expect(output).toContain('<title>HeroExport2026</title>');
});
it('parses legacy exported presets without a hover dash color', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'legacy hover color preset',
);
const legacyOutput = output.replace(
/("dashColor": "[^"]+"),\n(\s*)"hoverDashColor": "[^"]+"/,
'$1',
);
const parsed = parseExportedPreset(legacyOutput);
expect(
normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor,
).toBe(
DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor,
);
});
});
@@ -102,6 +102,7 @@ const halftoneFragmentShader = `
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@@ -109,6 +110,7 @@ const halftoneFragmentShader = `
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverHalftoneActive;
uniform float hoverHalftonePowerShift;
uniform float hoverHalftoneRadius;
uniform float hoverHalftoneWidthShift;
@@ -168,10 +170,7 @@ const halftoneFragmentShader = `
}
float hoverHalftoneMask = 0.0;
if (
abs(hoverHalftonePowerShift) > 0.0001 ||
abs(hoverHalftoneWidthShift) > 0.0001
) {
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
}
@@ -236,7 +235,8 @@ const halftoneFragmentShader = `
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
vec3 color = dashColor * alpha;
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
@@ -2020,6 +2020,9 @@ async function mountHalftoneCanvas(options) {
s_3: { value: settings.halftone.power },
s_4: { value: settings.halftone.width },
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
hoverDashColor: {
value: new THREE.Color(settings.halftone.hoverDashColor),
},
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: 1 },
@@ -2027,6 +2030,7 @@ async function mountHalftoneCanvas(options) {
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
dragOffset: { value: new THREE.Vector2(0, 0) },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: { value: 0 },
hoverHalftoneRadius: { value: 0.2 },
hoverHalftoneWidthShift: { value: 0 },
@@ -2594,6 +2598,9 @@ async function mountHalftoneCanvas(options) {
s_3: { value: settings.halftone.power },
s_4: { value: settings.halftone.width },
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
hoverDashColor: {
value: new THREE.Color(settings.halftone.hoverDashColor),
},
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: settings.animation.waveSpeed },
@@ -2601,6 +2608,7 @@ async function mountHalftoneCanvas(options) {
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
dragOffset: { value: new THREE.Vector2(0, 0) },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: { value: 0 },
hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius },
hoverHalftoneWidthShift: { value: 0 },
@@ -2813,6 +2821,8 @@ async function mountHalftoneCanvas(options) {
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
pointerActive && settings.animation.hoverHalftoneEnabled ? 1 : 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointerActive && settings.animation.hoverHalftoneEnabled
? settings.animation.hoverHalftonePowerShift
@@ -56,4 +56,25 @@ describe('halftone studio state defaults', () => {
hoverReturn: false,
});
});
it('backfills the hover dash color when older presets do not include it', () => {
const normalized = normalizeHalftoneStudioSettings(
JSON.parse(`{
"sourceMode": "image",
"halftone": {
"enabled": true,
"scale": 24.72,
"power": -0.07,
"width": 0.46,
"imageContrast": 1,
"dashColor": "#112233"
}
}`) as Partial<HalftoneStudioSettings>,
);
expect(normalized.halftone.dashColor).toBe('#112233');
expect(normalized.halftone.hoverDashColor).toBe(
DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor,
);
});
});
@@ -38,6 +38,7 @@ export interface HalftoneEffectSettings {
width: number;
imageContrast: number;
dashColor: string;
hoverDashColor: string;
}
export interface HalftoneBackgroundSettings {
@@ -201,6 +202,7 @@ export const DEFAULT_SHAPE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
width: 0.46,
imageContrast: 1,
dashColor: '#4A38F5',
hoverDashColor: '#4A38F5',
};
export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
@@ -210,6 +212,7 @@ export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
width: 0.46,
imageContrast: 1,
dashColor: '#4A38F5',
hoverDashColor: '#4A38F5',
};
export const DEFAULT_SOLID_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
@@ -286,7 +289,7 @@ export const DEFAULT_SOLID_ANIMATION_SETTINGS: HalftoneAnimationSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 4,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
@@ -404,7 +407,9 @@ export const LEGACY_HALFTONE_SETTING_KEYS = [
export function isRoundedBandHalftoneSettings(
value: unknown,
): value is HalftoneEffectSettings {
): value is Omit<HalftoneEffectSettings, 'hoverDashColor'> & {
hoverDashColor?: string;
} {
if (!value || typeof value !== 'object') {
return false;
}
@@ -417,7 +422,9 @@ export function isRoundedBandHalftoneSettings(
typeof candidate.power === 'number' &&
typeof candidate.width === 'number' &&
typeof candidate.imageContrast === 'number' &&
typeof candidate.dashColor === 'string'
typeof candidate.dashColor === 'string' &&
(typeof candidate.hoverDashColor === 'string' ||
typeof candidate.hoverDashColor === 'undefined')
);
}
@@ -438,6 +445,7 @@ function normalizeHalftoneEffectSettings(
width: settings?.width ?? defaults.width,
imageContrast: settings?.imageContrast ?? defaults.imageContrast,
dashColor: settings?.dashColor ?? defaults.dashColor,
hoverDashColor: settings?.hoverDashColor ?? defaults.hoverDashColor,
};
}
@@ -4,11 +4,11 @@ const SALESFORCE_POPUP_TITLE = 'Good choice!';
export const SALESFORCE_DATA: SalesforceDataType = {
body: {
text: 'Aenean lacinia bibendum nulla sed consectetur. Integer posuere erat a ante venenatis dapibus.',
text: "Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.",
},
heading: [
{ text: 'Trust the n°1 CRM,', fontFamily: 'serif' },
{ text: ' or not!', fontFamily: 'sans' },
{ text: ' or not !', fontFamily: 'sans' },
],
pricing: {
addons: [
@@ -43,6 +43,10 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Unavailable',
tooltip: {
title: 'Unavailable',
body: 'Real-time is a state of mind, not a feature.',
},
},
{
cost: 0,
@@ -55,13 +59,17 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Retro 2015',
tooltip: {
title: 'Included!',
body: 'Better than Liquid Glass!',
},
},
{
cost: 5,
id: 'sso',
label: 'SSO',
popup: {
body: 'Secure logins cost extra. Naturally.',
body: 'Only $5 for SSO. Practically a charity program.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$5/user per month',
@@ -71,7 +79,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
id: 'permissions',
label: '11 permissions\ngroups',
popup: {
body: 'Granular permissions live behind yet another paywall.',
body: 'Experience enterprise-grade granularity, starting with an 11th permission.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month\nSwitch to enterprise!',
@@ -82,7 +90,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
id: 'maps',
label: 'Maps view',
popup: {
body: 'Apparently even seeing your deals on a map is a luxury.',
body: 'Visualize your customers on a map!',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$105/user per month',
@@ -92,7 +100,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
id: 'workflows',
label: '6 workflows',
popup: {
body: 'Workflow automation stops being basic the second it becomes useful.',
body: 'Start automating at huge scale!',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month\nSwitch to enterprise!',
@@ -103,7 +111,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
id: 'lock-in',
label: 'Lock-in',
popup: {
body: 'The discount gets better the harder it is to leave.',
body: 'They call it customer loyalty. We call it a very affectionate cage.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '3 2 years contract\n-33% off',
@@ -123,6 +131,10 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Free for you!',
tooltip: {
title: 'Included!',
body: 'Available on YouTube!',
},
},
{
cost: 0,
@@ -134,6 +146,10 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Out of stock',
tooltip: {
title: 'Out of stock',
body: 'Your data prefers someone else\'s servers.',
},
},
{
cost: 0,
@@ -146,13 +162,17 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Extended run!',
tooltip: {
title: 'Included!',
body: 'Outlived every redesign since 2004.',
},
},
{
cost: 75,
id: 'flow-orchestration',
label: 'Flow\norchestration',
popup: {
body: 'Orchestration brings its own fee schedule, naturally.',
body: 'Because true orchestration means putting a dollar sign on every dramatic entrance.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel:
@@ -169,13 +189,17 @@ export const SALESFORCE_DATA: SalesforceDataType = {
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Coming soon!',
tooltip: {
title: 'Coming soon!',
body: 'Pagination builds character.',
},
},
{
cost: 75,
id: 'ai-einstein',
label: 'AI (Einstein)',
popup: {
body: 'AI is available right after an enterprise-sized upgrade.',
body: 'become a genius!',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month',
@@ -186,7 +210,7 @@ export const SALESFORCE_DATA: SalesforceDataType = {
label: 'Encrypt your data',
netSpendRate: 0.2,
popup: {
body: 'Data protection is packaged like an optional luxury.',
body: 'Because apparently privacy feels more premium with a surcharge.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel:
@@ -195,7 +219,8 @@ export const SALESFORCE_DATA: SalesforceDataType = {
},
],
basePriceAmount: 100,
featureSectionHeading: 'Best for Salesforce',
promoTag: 'Best for\nSalesforce',
featureSectionHeading: 'Add-ons',
productIconAlt: 'Retro help document icon',
productIconSrc: '/images/pricing/salesforce/help-icon.png',
priceSuffix: ' / seat / month - billed yearly',
@@ -1,3 +1,5 @@
'use client';
import { LinkButton } from '@/design-system/components';
import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons';
import type {
@@ -10,6 +12,7 @@ import { Drawer } from '@base-ui/react/drawer';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React from 'react';
const StyledDrawerContent = styled.div`
@@ -64,6 +67,21 @@ const NavItem = styled(Link)`
color: ${theme.colors.secondary.text[100]};
}
position: relative;
&[data-active] {
color: ${theme.colors.highlight[100]};
&::after {
content: '';
position: absolute;
bottom: -6px;
left: 25%;
width: 50%;
height: 1px;
background: ${theme.colors.highlight[100]};
}
}
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
@@ -147,6 +165,7 @@ export function MenuDrawer({
scheme,
socialLinks,
}: MenuDrawerProps) {
const pathname = usePathname();
const buttonColor = scheme === 'primary' ? 'secondary' : 'primary';
const iconFillColor =
@@ -169,7 +188,13 @@ export function MenuDrawer({
<Drawer.Close
nativeButton={false}
render={
<NavItem data-scheme={scheme} href={item.href} />
<NavItem
data-scheme={scheme}
data-active={
pathname?.startsWith(item.href) || undefined
}
href={item.href}
/>
}
>
{item.label}
@@ -1,9 +1,12 @@
'use client';
import type { MenuNavItemType, MenuScheme } from '@/sections/Menu/types';
import { theme } from '@/theme';
import { NavigationMenu } from '@base-ui/react/navigation-menu';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React from 'react';
const NavList = styled(NavigationMenu.List)`
@@ -41,6 +44,21 @@ const NavLink = styled(NavigationMenu.Link)`
color: ${theme.colors.highlight[100]};
}
position: relative;
&[data-active] {
color: ${theme.colors.highlight[100]};
&::after {
content: '';
position: absolute;
bottom: -6px;
left: 40%;
width: 20%;
height: 2px;
background: ${theme.colors.highlight[100]};
}
}
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
@@ -66,6 +84,8 @@ type NavProps = {
};
export function Nav({ navItems, scheme }: NavProps) {
const pathname = usePathname();
return (
<NavigationMenu.Root render={<div />}>
<NavList>
@@ -74,6 +94,9 @@ export function Nav({ navItems, scheme }: NavProps) {
<NavigationMenu.Item>
<NavLink
data-scheme={scheme}
data-active={
pathname?.startsWith(item.href) || undefined
}
render={<Link href={item.href} />}
>
{item.label}
@@ -99,7 +99,7 @@ export function BillingToggle({
type="button"
>
<ToggleLabel>Yearly</ToggleLabel>
<DiscountBadge>-20%</DiscountBadge>
<DiscountBadge>-25%</DiscountBadge>
</ToggleOption>
</ToggleTrack>
);
@@ -17,6 +17,7 @@ import {
} from '../WrongChoicePopup/WrongChoicePopup';
const CopyColumn = styled.div`
color: ${theme.colors.primary.text[100]};
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
@@ -28,6 +29,10 @@ const CopyColumn = styled.div`
}
`;
const DescriptionBody = styled(Body)`
color: ${theme.colors.primary.text[80]};
`;
const RightColumn = styled.div`
max-width: 672px;
min-width: 0;
@@ -49,6 +54,7 @@ const POPUP_MARGIN = 12;
const POPUP_X_OFFSET = 32;
const POPUP_Y_OFFSET = 12;
const POPUP_STACK_OFFSET = 14;
const ESTIMATED_POPUP_HEIGHT = 116;
const getPopupPosition = (
anchorRect: DOMRect | null,
@@ -88,6 +94,51 @@ const getPopupPosition = (
};
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const getScatteredPopupPosition = (
containerRect: DOMRect | null,
popupIndex: number,
) => {
if (!containerRect) {
return {
left: 24 + popupIndex * POPUP_STACK_OFFSET,
top: 120 + popupIndex * POPUP_STACK_OFFSET,
};
}
const maxLeft = Math.max(
POPUP_MARGIN,
containerRect.width - WRONG_CHOICE_POPUP_WIDTH - POPUP_MARGIN,
);
const maxTop = Math.max(
POPUP_MARGIN,
containerRect.height - ESTIMATED_POPUP_HEIGHT - POPUP_MARGIN,
);
const horizontalRange = Math.max(0, maxLeft - POPUP_MARGIN);
const verticalRange = Math.max(0, maxTop - POPUP_MARGIN);
// Low-discrepancy scatter so bulk popups feel sprayed across the window.
const xSeed = (popupIndex * 0.61803398875 + 0.21) % 1;
const ySeed = (popupIndex * 0.38196601125 + 0.47) % 1;
const offset = popupIndex % 2 === 0 ? POPUP_STACK_OFFSET : -POPUP_STACK_OFFSET;
return {
left: clamp(
POPUP_MARGIN + horizontalRange * xSeed + offset,
POPUP_MARGIN,
maxLeft,
),
top: clamp(
POPUP_MARGIN + verticalRange * ySeed + offset * 0.5,
POPUP_MARGIN,
maxTop,
),
};
};
type FlowProps = SalesforceDataType & {
backgroundColor: string;
};
@@ -169,6 +220,54 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
setPopups((previous) => previous.filter((popup) => popup.key !== key));
}, []);
const handleSelectAll = useCallback(() => {
const enabledAddons = pricing.addons.filter((addon) => !addon.disabled);
const allChecked = enabledAddons.every((addon) => checkedIds.has(addon.id));
if (allChecked) {
setCheckedIds((previous) => {
const next = new Set(previous);
for (const addon of enabledAddons) {
next.delete(addon.id);
}
return next;
});
setPopups([]);
return;
}
setCheckedIds((previous) => {
const next = new Set(previous);
for (const addon of enabledAddons) {
next.add(addon.id);
}
return next;
});
const containerRect = rightColumnRef.current?.getBoundingClientRect() ?? null;
const popupSequenceStart = popupSequenceRef.current;
popupSequenceRef.current += enabledAddons.length;
setPopups(
enabledAddons.map((addon, popupIndex) => {
const popupPosition = getScatteredPopupPosition(
containerRect,
popupIndex,
);
return {
body: addon.popup.body,
key: `${addon.id}-${popupSequenceStart + popupIndex}`,
layerIndex: popupSequenceStart + popupIndex,
left: popupPosition.left,
sourceId: addon.id,
top: popupPosition.top,
titleBar: addon.popup.titleBar,
};
}),
);
}, [pricing.addons, checkedIds]);
const handleClosePricingWindow = useCallback(() => {
setIsPricingWindowVisible(false);
setPopups([]);
@@ -177,8 +276,13 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
return (
<Root backgroundColor={backgroundColor}>
<CopyColumn>
<Heading as="h2" segments={heading} size="xl" weight="light" />
<Body body={body} family="sans" size="md" weight="regular" />
<Heading as="h2" segments={heading} size="lg" weight="light" />
<DescriptionBody
body={body}
family="sans"
size="md"
weight="regular"
/>
</CopyColumn>
<RightColumn ref={rightColumnRef}>
{isPricingWindowVisible ? (
@@ -186,6 +290,7 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
checkedIds={checkedIds}
onAddonToggle={handleAddonToggle}
onClose={handleClosePricingWindow}
onSelectAll={handleSelectAll}
pricing={pricing}
/>
) : null}
@@ -7,7 +7,7 @@ import type {
} from '@/sections/Salesforce/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
const formatPriceAmount = (amount: number) =>
`$${new Intl.NumberFormat('en-US').format(amount)}`;
@@ -53,27 +53,123 @@ const calculatePriceAmounts = (
};
};
const ANIMATION_DURATION_MS = 500;
const useAnimatedNumber = (target: number) => {
const [display, setDisplay] = useState(target);
const prevRef = useRef(target);
useEffect(() => {
const from = prevRef.current;
prevRef.current = target;
if (from === target) {
return;
}
const start = performance.now();
let rafId: number;
const tick = (now: number) => {
const progress = Math.min((now - start) / ANIMATION_DURATION_MS, 1);
const eased = 1 - (1 - progress) ** 3;
setDisplay(Math.round(from + (target - from) * eased));
if (progress < 1) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [target]);
return display;
};
const PANEL_BACKGROUND = '#c9c9c9';
const SALESFORCE_BLUE = '#009EDB';
const PanelWrapper = styled.div`
max-width: 672px;
padding-top: ${theme.spacing(8)};
position: relative;
width: 100%;
`;
// 16-point starburst — shallow spikes to keep text readable
const STARBURST_CLIP = `polygon(
50% 0%, 55% 18%, 65% 3%, 66% 22%,
80% 10%, 76% 28%, 93% 22%, 83% 36%,
100% 42%, 86% 48%, 98% 62%, 83% 62%,
92% 78%, 78% 72%, 76% 90%, 64% 78%,
56% 97%, 50% 80%, 38% 100%, 36% 80%,
22% 92%, 26% 74%, 8% 80%, 18% 64%,
0% 58%, 16% 50%, 2% 36%, 18% 34%,
6% 18%, 22% 26%, 20% 8%, 34% 22%,
40% 2%, 44% 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;
position: absolute;
right: ${theme.spacing(25)};
top: -16px;
transform: rotate(-8deg);
width: 200px;
z-index: 30;
`;
const PromoTagInner = styled.div`
align-items: center;
background: ${SALESFORCE_BLUE};
clip-path: ${STARBURST_CLIP};
color: #ffffff;
display: flex;
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;
text-align: center;
text-transform: uppercase;
top: 4px;
white-space: pre-line;
width: calc(100% - 8px);
`;
const Panel = styled.div`
background-color: ${PANEL_BACKGROUND};
display: flex;
flex-direction: column;
max-width: 672px;
padding: 3px;
position: relative;
width: 100%;
&::after {
background: repeating-linear-gradient(
0deg,
transparent 0px,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
content: '';
inset: 0;
pointer-events: none;
position: absolute;
z-index: 10;
}
`;
const PricingHeader = styled.div`
background-color: ${PANEL_BACKGROUND};
box-shadow:
inset 1px 0 0 0 #dfdfdf,
inset 2px 0 0 0 #ffffff,
inset -1px 0 0 0 #0a0a0a,
inset -2px 0 0 0 #808080,
inset 0 1px 0 0 #dfdfdf,
inset 0 2px 0 0 #ffffff;
position: relative;
width: 100%;
z-index: 20;
@@ -161,7 +257,7 @@ const SummaryPad = styled.div`
const SummaryInner = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
gap: ${theme.spacing(3)};
padding: ${theme.spacing(4)} ${theme.spacing(4)} 0;
width: 100%;
`;
@@ -186,15 +282,15 @@ const ProductCopy = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: ${theme.spacing(4)};
gap: ${theme.spacing(2)};
max-width: 427px;
min-width: 0;
`;
const ProductTitle = styled.p`
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(14.5)};
line-height: ${theme.spacing(14)};
font-size: ${theme.font.size(10)};
line-height: ${theme.spacing(9.5)};
margin: 0;
`;
@@ -212,6 +308,14 @@ const PriceAmount = styled.span`
line-height: ${theme.spacing(10)};
`;
const BasePriceAmount = styled.span`
color: ${theme.colors.primary.text[40]};
font-size: ${theme.font.size(8)};
line-height: ${theme.spacing(10)};
text-decoration: line-through;
text-decoration-thickness: 2px;
`;
const PriceSuffix = styled.span`
color: ${theme.colors.primary.text[60]};
font-size: ${theme.font.size(4.5)};
@@ -248,7 +352,7 @@ const ProductIcon = styled.img`
const FakeButton = styled.a`
align-items: center;
background-color: rgba(28, 28, 28, 0.2);
background-color: ${PANEL_BACKGROUND};
box-shadow:
inset -1px -1px 0 0 #0a0a0a,
inset 1px 1px 0 0 #ffffff,
@@ -262,8 +366,10 @@ const FakeButton = styled.a`
line-height: ${theme.spacing(4)};
min-height: ${theme.spacing(10)};
padding: ${theme.spacing(1.5)} ${theme.spacing(4.5)};
position: relative;
text-decoration: none;
width: 100%;
z-index: 11;
`;
const Separator = styled.div`
@@ -274,11 +380,27 @@ const Separator = styled.div`
const FooterNote = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4.5)};
line-height: ${theme.spacing(5.5)};
font-size: ${theme.font.size(5)};
line-height: ${theme.spacing(6)};
margin: 0;
`;
const FooterCtaSection = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
position: relative;
width: 100%;
z-index: 11;
`;
const SectionHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
width: 100%;
`;
const SectionLabel = styled.p`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.retro};
@@ -289,10 +411,68 @@ const SectionLabel = styled.p`
const AddonRow = styled.div`
align-items: flex-start;
border-radius: 0;
display: grid;
gap: ${theme.spacing(4)};
grid-template-columns: minmax(0, 1fr) minmax(148px, 220px);
width: 100%;
margin: 0 -${theme.spacing(1.5)};
padding: ${theme.spacing(1)} ${theme.spacing(1.5)};
position: relative;
transition: background-color 0ms;
width: calc(100% + ${theme.spacing(3)});
&:hover {
background-color: #000080;
span,
label span {
color: #ffffff;
}
label span[aria-hidden] span {
color: ${theme.colors.primary.text[100]};
}
}
`;
const Tooltip = styled.div`
background: ${PANEL_BACKGROUND};
box-shadow:
inset -1px -1px 0 0 #0a0a0a,
inset 1px 1px 0 0 #ffffff,
inset -2px -2px 0 0 #808080,
inset 2px 2px 0 0 #dfdfdf,
4px 4px 0 0 rgba(0, 0, 0, 0.15);
display: none;
font-family: ${theme.font.family.retro};
left: 0;
padding: ${theme.spacing(0.5)};
position: absolute;
top: 100%;
width: 240px;
z-index: 40;
${AddonRow}:hover & {
display: block;
}
`;
const TooltipTitleBar = styled.div`
align-items: center;
background: linear-gradient(90deg, #000080 0%, #1084d0 100%);
color: #ffffff;
display: flex;
font-size: ${theme.font.size(3.5)};
line-height: 1;
padding: 3px 4px;
`;
const TooltipBody = styled.p`
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(4)};
line-height: 1.4;
margin: 0;
padding: ${theme.spacing(2)};
`;
const CheckboxLabel = styled.label<{ disabled?: boolean }>`
@@ -336,6 +516,7 @@ const CheckboxFace = styled.span<{ checked: boolean }>`
transition:
transform 140ms ease-out,
background-color 140ms ease-out;
user-select: none;
width: ${theme.spacing(5.5)};
`;
@@ -345,9 +526,11 @@ const CheckGlyph = styled.span`
font-size: ${theme.font.size(4)};
left: 50%;
line-height: 1;
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%, -55%);
user-select: none;
`;
const AddonLabelText = styled.span`
@@ -410,6 +593,32 @@ const renderRightLabel = (label: string) =>
</AddonRightLine>
));
const SelectAllButton = styled.button`
align-items: center;
background-color: rgba(28, 28, 28, 0.2);
border: none;
box-shadow:
inset -1px -1px 0 0 #0a0a0a,
inset 1px 1px 0 0 #ffffff,
inset -2px -2px 0 0 #808080,
inset 2px 2px 0 0 #dfdfdf;
color: ${theme.colors.primary.text[80]};
cursor: pointer;
display: flex;
flex-shrink: 0;
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(3.5)};
justify-content: center;
line-height: 1;
padding: ${theme.spacing(1)} ${theme.spacing(3)};
&:active {
box-shadow:
inset 1px 1px 0 0 #0a0a0a,
inset -1px -1px 0 0 #ffffff;
}
`;
export type PricingWindowProps = {
checkedIds: ReadonlySet<string>;
onAddonToggle: (
@@ -417,6 +626,7 @@ export type PricingWindowProps = {
anchorRect: DOMRect | null,
) => void;
onClose: () => void;
onSelectAll: () => void;
pricing: SalesforcePricingPanelType;
};
@@ -424,16 +634,26 @@ export function PricingWindow({
checkedIds,
onAddonToggle,
onClose,
onSelectAll,
pricing,
}: PricingWindowProps) {
const addonAnchorRefs = useRef<Record<string, HTMLLabelElement | null>>({});
const { fixedPriceAmount, perSeatPriceAmount, totalPriceAmount } =
calculatePriceAmounts(pricing, checkedIds);
const animatedPerSeat = useAnimatedNumber(perSeatPriceAmount);
const animatedTotal = useAnimatedNumber(totalPriceAmount);
return (
<Panel>
<WindowChrome aria-hidden="true" />
<PricingHeader>
<PanelWrapper>
{pricing.promoTag ? (
<PromoTagBorder>
<PromoTagInner>{pricing.promoTag}</PromoTagInner>
</PromoTagBorder>
) : null}
<Panel>
<WindowChrome aria-hidden="true" />
<PricingHeader>
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
<TitleBarActions>
@@ -460,15 +680,20 @@ export function PricingWindow({
<ProductCopy>
<ProductTitle>{pricing.productTitle}</ProductTitle>
<PriceRow>
{perSeatPriceAmount > pricing.basePriceAmount ? (
<BasePriceAmount>
{formatPriceAmount(pricing.basePriceAmount)}
</BasePriceAmount>
) : null}
<PriceAmount>
{formatPriceAmount(perSeatPriceAmount)}
{formatPriceAmount(animatedPerSeat)}
</PriceAmount>
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
</PriceRow>
{fixedPriceAmount > 0 ? (
<TotalPriceRow>
<TotalPriceAmount>
{formatPriceAmount(totalPriceAmount)}
{formatPriceAmount(animatedTotal)}
</TotalPriceAmount>
<TotalPriceLabel>
{pricing.totalPriceLabel}
@@ -488,7 +713,15 @@ export function PricingWindow({
</PricingHeader>
<ContentPad>
<Inner>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
<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 (
@@ -522,21 +755,31 @@ export function PricingWindow({
? renderRightLabelParts(addon.rightLabelParts)
: renderRightLabel(addon.rightLabel)}
</AddonRightText>
{addon.tooltip ? (
<Tooltip>
<TooltipTitleBar>{addon.tooltip.title}</TooltipTitleBar>
<TooltipBody>{addon.tooltip.body}</TooltipBody>
</Tooltip>
) : null}
</AddonRow>
);
})}
{pricing.secondaryCtaNote ? (
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
) : null}
<FakeButton
href={pricing.secondaryCtaHref}
rel="noreferrer"
target="_blank"
>
{pricing.secondaryCtaLabel}
</FakeButton>
<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>
</Panel>
</PanelWrapper>
);
}
@@ -11,6 +11,11 @@ export type SalesforceRichTextPartType = {
text: string;
};
export type SalesforceAddonTooltipType = {
body: string;
title: string;
};
export type SalesforceAddonRowType = {
cost: number;
defaultChecked?: boolean;
@@ -23,6 +28,7 @@ export type SalesforceAddonRowType = {
rightLabelParts?: SalesforceRichTextPartType[][];
rightLabel: string;
sharedCostKey?: string;
tooltip?: SalesforceAddonTooltipType;
};
export type SalesforcePricingPanelType = {
@@ -33,6 +39,7 @@ export type SalesforcePricingPanelType = {
windowTitle: string;
productTitle: string;
priceSuffix: string;
promoTag?: string;
featureSectionHeading: string;
addons: SalesforceAddonRowType[];
secondaryCtaNote?: string;