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:
committed by
GitHub
parent
c67602f2e8
commit
87bb2f94bb
@@ -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}
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+278
-35
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user