Salesforce section (#19366)

## Summary
- add the retro `VT323` font to the marketing site and apply it across
the Salesforce pricing card and popups
- expand the Salesforce pricing simulator with per-row metadata, unique
popup messages, dynamic price calculation, enterprise shared-cost
handling, and fixed-cost totals
- align the Salesforce card UI with the wireframes: sticky pricing
header, updated checkbox states, popup styling/behavior, add-on link,
and footer cleanup
- remove obsolete shared popup constants and quote form logic tied to
the Salesforce card
- refresh nearby pricing page UI details, including sticky menu behavior
and related pricing section polish

## Testing
- `yarn workspace twenty-website-new build`
This commit is contained in:
Thomas des Francs
2026-04-07 09:14:31 +02:00
committed by GitHub
parent c7d1cd11e0
commit ea4ef99565
12 changed files with 847 additions and 159 deletions

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -8,7 +8,7 @@ import { cssVariables } from '@/theme/css-variables';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
import { Aleo, Azeret_Mono, Host_Grotesk } from 'next/font/google';
import { Aleo, Azeret_Mono, Host_Grotesk, VT323 } from 'next/font/google';
import '../../../twenty-ui/dist/theme-light.css';
const hostGrotesk = Host_Grotesk({
@@ -32,6 +32,13 @@ const azeretMono = Azeret_Mono({
display: 'swap',
});
const vt323 = VT323({
subsets: ['latin'],
weight: '400',
variable: '--font-retro',
display: 'swap',
});
css`
:global(*),
:global(*::before),
@@ -77,7 +84,7 @@ export default async function RootLayout({
return (
<html lang="en">
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable}`}
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
>
<GlbWarmCache />
<StyledMain>{children}</StyledMain>

View File

@@ -1,5 +1,7 @@
import type { SalesforceDataType } from '@/sections/Salesforce/types';
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.',
@@ -11,56 +13,198 @@ export const SALESFORCE_DATA: SalesforceDataType = {
pricing: {
addons: [
{
cost: 35,
id: 'api-access',
label: 'API access',
popupKey: 'wrongChoiceDefault',
popup: {
body: 'APIs are extra. Simplicity has a price.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$35/user per month',
},
{
cost: 0,
fixedCost: 7000,
id: 'webhooks',
label: 'Webhooks (Change data capture)',
popupKey: 'wrongChoiceLorem',
label: 'Webhooks (Change Data Capture)',
popup: {
body: 'Real-time changes? That will be a premium surprise.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$7000/org per month',
},
{
cost: 0,
disabled: true,
id: 'live-updates',
label: 'Live updates',
popupKey: 'wrongChoiceDefault',
popup: {
body: 'Live updates are unavailable, which is almost more honest.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Unavailable',
},
{
cost: 0,
defaultChecked: true,
disabled: true,
id: 'ui-theme',
label: 'UI theme',
popupKey: 'wrongChoiceDefault',
popup: {
body: 'A retro theme as a paid add-on is somehow the most believable part.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Retro 2015',
},
{
cost: 5,
id: 'sso',
label: 'SSO',
popupKey: 'wrongChoiceDefault',
popup: {
body: 'Secure logins cost extra. Naturally.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$5/user per month',
},
{
cost: 75,
id: 'permissions',
label: '11 permissions groups',
popupKey: 'wrongChoiceDefault',
rightLabel: '+$75/user per month (Switch to enterprise!)',
label: '11 permissions\ngroups',
popup: {
body: 'Granular permissions live behind yet another paywall.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month\nSwitch to enterprise!',
sharedCostKey: 'enterprise-plan',
},
{
cost: 105,
id: 'maps',
label: 'Maps view',
popupKey: 'wrongChoiceDefault',
popup: {
body: 'Apparently even seeing your deals on a map is a luxury.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$105/user per month',
},
{
cost: 75,
id: 'workflows',
label: '6 workflows',
popup: {
body: 'Workflow automation stops being basic the second it becomes useful.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month\nSwitch to enterprise!',
sharedCostKey: 'enterprise-plan',
},
{
cost: 0,
id: 'lock-in',
label: 'Lock-in',
popup: {
body: 'The discount gets better the harder it is to leave.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '3 2 years contract\n-33% off',
rightLabelParts: [
[{ strike: true, text: '3' }, { text: ' 2 years contract' }],
[{ text: '-33% off' }],
],
},
{
cost: 0,
defaultChecked: true,
disabled: true,
id: 'apex-tutorials',
label: 'APEX tutorials',
popup: {
body: 'Even the training material is a feature worth celebrating.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Free for you!',
},
{
cost: 0,
disabled: true,
id: 'self-hosting',
label: 'Self-hosting',
popup: {
body: 'Owning your stack remains mysteriously out of stock.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Out of stock',
},
{
cost: 0,
defaultChecked: true,
disabled: true,
id: 'salesforce-classic',
label: 'Salesforce Classic',
popup: {
body: 'Classic never dies. It just gets extended one more time.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Extended run!',
},
{
cost: 75,
id: 'flow-orchestration',
label: 'Flow\norchestration',
popup: {
body: 'Orchestration brings its own fee schedule, naturally.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel:
'$1/orchestration run/org\n+$75/user per month\nSwitch to enterprise!',
sharedCostKey: 'enterprise-plan',
},
{
cost: 0,
disabled: true,
id: 'infinite-scroll',
label: 'Infinite scroll',
popup: {
body: 'Infinite scroll is still coming soon, unlike the invoice.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: 'Coming soon!',
},
{
cost: 75,
id: 'ai-einstein',
label: 'AI (Einstein)',
popup: {
body: 'AI is available right after an enterprise-sized upgrade.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month',
},
{
cost: 75,
id: 'encrypt-data',
label: 'Encrypt your data',
netSpendRate: 0.2,
popup: {
body: 'Data protection is packaged like an optional luxury.',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel:
'+20% of net spend\n+$75/user per month\nSwitch to enterprise!',
sharedCostKey: 'enterprise-plan',
},
],
basePriceAmount: 100,
featureSectionHeading: 'Best for Salesforce',
priceAmount: '$100 USD',
productIconAlt: 'Retro help document icon',
productIconSrc: '/images/pricing/salesforce/help-icon.png',
priceSuffix: ' / seat / month - billed yearly',
primaryCtaLabel: 'Do you really want to click and ask your first quote ???',
productTitle: 'Salesforce Pro',
secondaryCtaLabel: "More options available, BUT DON'T CLICK !!",
secondaryCtaNote: 'More options available!',
secondaryCtaHref:
'https://www.salesforce.com/en-us/wp-content/uploads/sites/4/documents/pricing/all-add-ons.pdf',
secondaryCtaLabel: 'Check more add-ons',
totalPriceLabel: 'total per month with fixed cost',
windowTitle: 'Salesforce Pro pricing',
},
};

View File

@@ -15,8 +15,12 @@ import { CloseDrawerWhenNavigationExpandsEffect } from '../../effect-components/
import { MenuDrawer } from '../Drawer/Drawer';
const StyledSection = styled.section`
backdrop-filter: blur(10px);
min-width: 0;
position: sticky;
top: 0;
width: 100%;
z-index: 200;
`;
const StyledContainer = styled(Container)`

View File

@@ -1,17 +1,20 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import { SALESFORCE_POPUPS } from '@/sections/Salesforce/constants';
import type {
SalesforceAddonRowType,
SalesforceDataType,
SalesforceWrongChoicePopupType,
} from '@/sections/Salesforce/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { PricingWindow } from '../PricingWindow/PricingWindow';
import { Root } from '../Root/Root';
import { WrongChoicePopup } from '../WrongChoicePopup/WrongChoicePopup';
import {
WrongChoicePopup,
WRONG_CHOICE_POPUP_WIDTH,
} from '../WrongChoicePopup/WrongChoicePopup';
const CopyColumn = styled.div`
display: flex;
@@ -29,23 +32,66 @@ const RightColumn = styled.div`
`;
type OpenWrongChoicePopup = {
addonId: string;
body: string;
key: string;
stackIndex: number;
layerIndex: number;
left: number;
sourceId: string;
top: number;
titleBar: string;
};
const reindexPopups = (
list: OpenWrongChoicePopup[],
): OpenWrongChoicePopup[] =>
list.map((popup, index) => ({ ...popup, stackIndex: index }));
const POPUP_MARGIN = 12;
const POPUP_X_OFFSET = 32;
const POPUP_Y_OFFSET = 12;
const POPUP_STACK_OFFSET = 14;
const getPopupPosition = (
anchorRect: DOMRect | null,
containerRect: DOMRect | null,
stackIndex = 0,
) => {
if (!anchorRect || !containerRect) {
return {
left: 24 + stackIndex * POPUP_STACK_OFFSET,
top: 120 + stackIndex * POPUP_STACK_OFFSET,
};
}
const maxLeft = Math.max(
POPUP_MARGIN,
containerRect.width - WRONG_CHOICE_POPUP_WIDTH - POPUP_MARGIN,
);
return {
left: Math.min(
Math.max(
anchorRect.left -
containerRect.left +
POPUP_X_OFFSET +
stackIndex * POPUP_STACK_OFFSET,
POPUP_MARGIN,
),
maxLeft,
),
top: Math.max(
anchorRect.top -
containerRect.top -
POPUP_Y_OFFSET +
stackIndex * POPUP_STACK_OFFSET,
POPUP_MARGIN,
),
};
};
type FlowProps = SalesforceDataType & {
backgroundColor: string;
};
export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
const rightColumnRef = useRef<HTMLDivElement>(null);
const popupSequenceRef = useRef(0);
const [isPricingWindowVisible, setIsPricingWindowVisible] = useState(true);
const [checkedIds, setCheckedIds] = useState(() => {
const initial = new Set<string>();
for (const row of pricing.addons) {
@@ -58,22 +104,51 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
const [popups, setPopups] = useState<OpenWrongChoicePopup[]>([]);
const openPopup = useCallback(
(
sourceId: string,
popup: SalesforceWrongChoicePopupType,
anchorRect: DOMRect | null,
) => {
const popupSequence = popupSequenceRef.current;
popupSequenceRef.current += 1;
setPopups((previous) => {
const popupCountForSource = previous.filter(
(entry) => entry.sourceId === sourceId,
).length;
const popupPosition = getPopupPosition(
anchorRect,
rightColumnRef.current?.getBoundingClientRect() ?? null,
popupCountForSource,
);
return [
...previous,
{
body: popup.body,
key: `${sourceId}-${popupSequence}`,
layerIndex: popupSequence,
left: popupPosition.left,
sourceId,
top: popupPosition.top,
titleBar: popup.titleBar,
},
];
});
},
[],
);
const handleAddonToggle = useCallback(
(addon: SalesforceAddonRowType) => {
(addon: SalesforceAddonRowType, anchorRect: DOMRect | null) => {
if (addon.disabled) {
return;
}
const popupTemplate = SALESFORCE_POPUPS[addon.popupKey];
if (!popupTemplate) {
return;
}
const wasChecked = checkedIds.has(addon.id);
setCheckedIds((previous) => {
const next = new Set(previous);
if (wasChecked) {
if (next.has(addon.id)) {
next.delete(addon.id);
} else {
next.add(addon.id);
@@ -81,32 +156,18 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
return next;
});
if (wasChecked) {
setPopups((previous) =>
reindexPopups(
previous.filter((popup) => popup.addonId !== addon.id),
),
);
} else {
setPopups((previous) => [
...previous,
{
addonId: addon.id,
body: popupTemplate.body,
key: `${addon.id}-${Date.now()}`,
stackIndex: previous.length,
titleBar: popupTemplate.titleBar,
},
]);
}
openPopup(addon.id, addon.popup, anchorRect);
},
[checkedIds],
[openPopup],
);
const handleClosePopup = useCallback((key: string) => {
setPopups((previous) =>
reindexPopups(previous.filter((popup) => popup.key !== key)),
);
setPopups((previous) => previous.filter((popup) => popup.key !== key));
}, []);
const handleClosePricingWindow = useCallback(() => {
setIsPricingWindowVisible(false);
setPopups([]);
}, []);
return (
@@ -115,24 +176,31 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
<Heading as="h2" segments={heading} size="xl" weight="light" />
<Body body={body} family="sans" size="md" weight="regular" />
</CopyColumn>
<RightColumn>
<PricingWindow
checkedIds={checkedIds}
onAddonToggle={handleAddonToggle}
pricing={pricing}
/>
{popups.map((popup) => (
<WrongChoicePopup
body={popup.body}
key={popup.key}
onClose={() => {
handleClosePopup(popup.key);
}}
stackIndex={popup.stackIndex}
titleBar={popup.titleBar}
titleId={`sf-wrong-choice-${popup.key}`}
<RightColumn ref={rightColumnRef}>
{isPricingWindowVisible ? (
<PricingWindow
checkedIds={checkedIds}
onAddonToggle={handleAddonToggle}
onClose={handleClosePricingWindow}
pricing={pricing}
/>
))}
) : null}
{isPricingWindowVisible
? popups.map((popup) => (
<WrongChoicePopup
body={popup.body}
key={popup.key}
layerIndex={popup.layerIndex}
left={popup.left}
onClose={() => {
handleClosePopup(popup.key);
}}
top={popup.top}
titleBar={popup.titleBar}
titleId={`sf-wrong-choice-${popup.key}`}
/>
))
: null}
</RightColumn>
</Root>
);

View File

@@ -3,12 +3,64 @@
import type {
SalesforceAddonRowType,
SalesforcePricingPanelType,
SalesforceRichTextPartType,
} from '@/sections/Salesforce/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
const formatPriceAmount = (amount: number) =>
`$${new Intl.NumberFormat('en-US').format(amount)}`;
const calculatePriceAmounts = (
pricing: SalesforcePricingPanelType,
checkedIds: ReadonlySet<string>,
) => {
const appliedSharedCosts = new Set<string>();
let perSeatBaseAmount = pricing.basePriceAmount;
let fixedPriceAmount = 0;
let netSpendRate = 0;
for (const addon of pricing.addons) {
if (!checkedIds.has(addon.id)) {
continue;
}
if (addon.sharedCostKey && appliedSharedCosts.has(addon.sharedCostKey)) {
fixedPriceAmount += addon.fixedCost ?? 0;
netSpendRate += addon.netSpendRate ?? 0;
continue;
}
if (addon.sharedCostKey) {
appliedSharedCosts.add(addon.sharedCostKey);
}
perSeatBaseAmount += addon.cost;
fixedPriceAmount += addon.fixedCost ?? 0;
netSpendRate += addon.netSpendRate ?? 0;
}
const perSeatPriceAmount = perSeatBaseAmount * (1 + netSpendRate);
const totalBaseAmount = perSeatBaseAmount + fixedPriceAmount;
const totalPriceAmount = totalBaseAmount * (1 + netSpendRate);
return {
fixedPriceAmount,
perSeatPriceAmount,
totalPriceAmount,
};
};
const PANEL_BACKGROUND = '#c9c9c9';
const CARD_STICKY_TOP_OFFSET_PX = 64;
const CARD_STICKY_BOTTOM_OFFSET_PX = 340;
type StickyHeaderMode = 'absolute' | 'fixed';
const Panel = styled.div`
background-color: rgba(28, 28, 28, 0.2);
background-color: ${PANEL_BACKGROUND};
display: flex;
flex-direction: column;
max-width: 672px;
@@ -17,6 +69,33 @@ const Panel = styled.div`
width: 100%;
`;
const StickyHeaderSpacer = styled.div<{ $height: number }>`
height: ${({ $height }) => $height}px;
width: 100%;
`;
const StickyHeader = styled.div<{
$absoluteTop: number;
$left: number;
$mode: StickyHeaderMode;
$width: number;
}>`
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;
left: ${({ $left, $mode }) => ($mode === 'fixed' ? `${$left}px` : '0')};
position: ${({ $mode }) => ($mode === 'fixed' ? 'fixed' : 'absolute')};
top: ${({ $absoluteTop, $mode }) =>
$mode === 'fixed' ? `${CARD_STICKY_TOP_OFFSET_PX}px` : `${$absoluteTop}px`};
width: ${({ $mode, $width }) => ($mode === 'fixed' ? `${$width}px` : '100%')};
z-index: 20;
`;
const TitleBar = styled.div`
align-items: center;
background: linear-gradient(90deg, #000080 0%, #1084d0 100%);
@@ -27,13 +106,45 @@ const TitleBar = styled.div`
`;
const TitleBarText = styled.p`
color: ${theme.colors.secondary.background[100]};
font-family: ${theme.font.family.mono};
color: ${theme.colors.secondary.text[100]};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4)};
line-height: 12px;
margin: 0;
`;
const TitleBarActions = styled.div`
display: flex;
gap: 2px;
`;
const TitleBarActionButton = styled.button`
align-items: center;
background: rgba(255, 255, 255, 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[100]};
cursor: pointer;
display: flex;
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(3.5)};
justify-content: center;
line-height: 1;
min-height: 18px;
min-width: 18px;
padding: 2px 4px;
&:active {
box-shadow:
inset 1px 1px 0 0 #0a0a0a,
inset -1px -1px 0 0 #ffffff;
}
`;
const WindowChrome = styled.div`
box-shadow:
inset -1px -1px 0 0 #0a0a0a,
@@ -46,7 +157,7 @@ const WindowChrome = styled.div`
`;
const ContentPad = styled.div`
padding: ${theme.spacing(2.75)};
padding: 0 ${theme.spacing(2.75)} ${theme.spacing(2.75)};
width: 100%;
`;
@@ -59,12 +170,42 @@ const Inner = styled.div`
width: 100%;
`;
const SummaryPad = styled.div`
padding: ${theme.spacing(2.75)} ${theme.spacing(2.75)} 0;
width: 100%;
`;
const SummaryInner = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
padding: ${theme.spacing(4)} ${theme.spacing(4)} 0;
width: 100%;
`;
const ProductBlock = styled.div`
display: flex;
flex-direction: column;
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
gap: ${theme.spacing(4)};
width: 100%;
`;
const ProductHeader = styled.div`
align-items: flex-start;
display: flex;
gap: ${theme.spacing(4)};
justify-content: space-between;
width: 100%;
`;
const ProductCopy = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: ${theme.spacing(4)};
max-width: 427px;
min-width: 0;
`;
const ProductTitle = styled.p`
@@ -94,7 +235,35 @@ const PriceSuffix = styled.span`
line-height: ${theme.spacing(5.5)};
`;
const FakeButton = styled.div`
const TotalPriceRow = styled.div`
align-items: baseline;
display: flex;
gap: ${theme.spacing(2)};
white-space: nowrap;
width: 100%;
`;
const TotalPriceAmount = styled.span`
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(8)};
line-height: ${theme.spacing(10)};
`;
const TotalPriceLabel = styled.span`
color: ${theme.colors.primary.text[60]};
font-size: ${theme.font.size(4.5)};
line-height: ${theme.spacing(5.5)};
`;
const ProductIcon = styled.img`
flex-shrink: 0;
height: 92px;
image-rendering: pixelated;
object-fit: contain;
width: 92px;
`;
const FakeButton = styled.a`
align-items: center;
background-color: rgba(28, 28, 28, 0.2);
box-shadow:
@@ -104,19 +273,32 @@ const FakeButton = styled.div`
inset 2px 2px 0 0 #dfdfdf;
color: ${theme.colors.primary.text[100]};
display: flex;
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4)};
justify-content: center;
line-height: ${theme.spacing(4)};
min-height: ${theme.spacing(10)};
padding: ${theme.spacing(1.5)} ${theme.spacing(4.5)};
text-transform: uppercase;
text-decoration: none;
width: 100%;
`;
const Separator = styled.div`
border-top: 1px solid ${theme.colors.secondary.border[100]};
width: 100%;
`;
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)};
margin: 0;
`;
const SectionLabel = styled.p`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(5)};
line-height: ${theme.spacing(6)};
margin: 0;
@@ -124,14 +306,14 @@ const SectionLabel = styled.p`
const AddonRow = styled.div`
align-items: flex-start;
display: flex;
justify-content: space-between;
display: grid;
gap: ${theme.spacing(4)};
grid-template-columns: minmax(0, 1fr) minmax(148px, 220px);
width: 100%;
`;
const CheckboxLabel = styled.label<{ disabled?: boolean }>`
align-items: center;
align-items: flex-start;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex: 1;
@@ -150,85 +332,306 @@ const HiddenCheckbox = styled.input`
`;
const CheckboxFace = styled.span<{ checked: boolean }>`
aspect-ratio: 1 / 1;
background-color: ${({ checked }) =>
checked ? theme.colors.secondary.background[100] : 'rgba(255, 255, 255, 0.05)'};
checked ? theme.colors.primary.background[100] : 'rgba(255, 255, 255, 0.05)'};
border-radius: 0;
box-sizing: border-box;
box-shadow:
inset -1.5px -1.5px 0 0 #ffffff,
inset 1.5px 1.5px 0 0 #808080,
inset -3px -3px 0 0 #dfdfdf,
inset 3px 3px 0 0 #0a0a0a;
display: block;
flex-shrink: 0;
height: ${theme.spacing(5)};
height: ${theme.spacing(5.5)};
position: relative;
width: ${theme.spacing(5)};
transform: ${({ checked }) => (checked ? 'scale(1.08)' : 'scale(1)')};
transform-origin: center;
transition:
transform 140ms ease-out,
background-color 140ms ease-out;
width: ${theme.spacing(5.5)};
`;
const CheckGlyph = styled.span`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-size: ${theme.font.size(4)};
left: 50%;
line-height: 1;
position: absolute;
top: 50%;
transform: translate(-50%, -58%);
transform: translate(-50%, -55%);
`;
const AddonLabelText = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4.5)};
line-height: ${theme.spacing(5.5)};
white-space: nowrap;
white-space: pre-line;
`;
const AddonRightText = styled.span`
color: ${theme.colors.primary.text[100]};
flex-shrink: 0;
font-family: ${theme.font.family.mono};
display: block;
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4.5)};
line-height: ${theme.spacing(5.5)};
text-align: right;
white-space: nowrap;
`;
const AddonRightLine = styled.span<{ muted?: boolean }>`
color: ${({ muted }) =>
muted ? theme.colors.primary.text[60] : theme.colors.primary.text[100]};
display: block;
`;
const AddonRightPart = styled.span`
color: inherit;
font: inherit;
`;
const renderRightLabelParts = (lines: SalesforceRichTextPartType[][]) =>
lines.map((line, lineIndex) => (
<AddonRightLine key={lineIndex} muted={lineIndex > 0}>
{line.map((part, partIndex) => (
<AddonRightPart
key={partIndex}
style={
part.strike
? {
textDecorationLine: 'line-through',
textDecorationThickness: '1px',
}
: undefined
}
>
{part.text}
</AddonRightPart>
))}
</AddonRightLine>
));
const renderRightLabel = (label: string) =>
label.split('\n').map((line, lineIndex) => (
<AddonRightLine key={`${lineIndex}-${line}`} muted={lineIndex > 0}>
{line}
</AddonRightLine>
));
export type PricingWindowProps = {
checkedIds: ReadonlySet<string>;
onAddonToggle: (addon: SalesforceAddonRowType) => void;
onAddonToggle: (
addon: SalesforceAddonRowType,
anchorRect: DOMRect | null,
) => void;
onClose: () => void;
pricing: SalesforcePricingPanelType;
};
type StickyHeaderState = {
absoluteTop: number;
height: number;
left: number;
mode: StickyHeaderMode;
width: number;
};
export function PricingWindow({
checkedIds,
onAddonToggle,
onClose,
pricing,
}: PricingWindowProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const addonAnchorRefs = useRef<Record<string, HTMLLabelElement | null>>({});
const [stickyHeaderState, setStickyHeaderState] = useState<StickyHeaderState>({
absoluteTop: 0,
height: 0,
left: 0,
mode: 'absolute',
width: 0,
});
const { fixedPriceAmount, perSeatPriceAmount, totalPriceAmount } =
calculatePriceAmounts(pricing, checkedIds);
useEffect(() => {
let frameId = 0;
const updateStickyHeaderState = () => {
frameId = 0;
const panel = panelRef.current;
const stickyHeader = stickyHeaderRef.current;
if (!panel || !stickyHeader) {
return;
}
const panelRect = panel.getBoundingClientRect();
const stickyHeight = stickyHeader.offsetHeight;
const panelHeight = panel.offsetHeight;
const panelTop = window.scrollY + panelRect.top;
const maxAbsoluteTop = Math.max(
0,
panelHeight - stickyHeight - CARD_STICKY_BOTTOM_OFFSET_PX,
);
const fixedEndScrollY =
panelTop +
panelHeight -
stickyHeight -
CARD_STICKY_TOP_OFFSET_PX -
CARD_STICKY_BOTTOM_OFFSET_PX;
const nextState: StickyHeaderState =
window.scrollY >= panelTop - CARD_STICKY_TOP_OFFSET_PX &&
window.scrollY < fixedEndScrollY
? {
absoluteTop: 0,
height: stickyHeight,
left: panelRect.left,
mode: 'fixed',
width: panelRect.width,
}
: {
absoluteTop:
window.scrollY >= fixedEndScrollY ? maxAbsoluteTop : 0,
height: stickyHeight,
left: 0,
mode: 'absolute',
width: panelRect.width,
};
setStickyHeaderState((previous) =>
previous.absoluteTop === nextState.absoluteTop &&
previous.height === nextState.height &&
previous.left === nextState.left &&
previous.mode === nextState.mode &&
previous.width === nextState.width
? previous
: nextState,
);
};
const requestStickyHeaderUpdate = () => {
if (frameId !== 0) {
return;
}
frameId = window.requestAnimationFrame(updateStickyHeaderState);
};
requestStickyHeaderUpdate();
window.addEventListener('resize', requestStickyHeaderUpdate);
window.addEventListener('scroll', requestStickyHeaderUpdate, {
passive: true,
});
const resizeObserver = new ResizeObserver(requestStickyHeaderUpdate);
if (panelRef.current) {
resizeObserver.observe(panelRef.current);
}
if (stickyHeaderRef.current) {
resizeObserver.observe(stickyHeaderRef.current);
}
return () => {
if (frameId !== 0) {
window.cancelAnimationFrame(frameId);
}
resizeObserver.disconnect();
window.removeEventListener('resize', requestStickyHeaderUpdate);
window.removeEventListener('scroll', requestStickyHeaderUpdate);
};
}, [fixedPriceAmount]);
return (
<Panel>
<Panel ref={panelRef}>
<WindowChrome aria-hidden="true" />
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
</TitleBar>
<StickyHeaderSpacer $height={stickyHeaderState.height} />
<StickyHeader
$absoluteTop={stickyHeaderState.absoluteTop}
$left={stickyHeaderState.left}
$mode={stickyHeaderState.mode}
$width={stickyHeaderState.width}
ref={stickyHeaderRef}
>
<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>
<PriceAmount>{formatPriceAmount(perSeatPriceAmount)}</PriceAmount>
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
</PriceRow>
{fixedPriceAmount > 0 ? (
<TotalPriceRow>
<TotalPriceAmount>
{formatPriceAmount(totalPriceAmount)}
</TotalPriceAmount>
<TotalPriceLabel>{pricing.totalPriceLabel}</TotalPriceLabel>
</TotalPriceRow>
) : null}
</ProductCopy>
<ProductIcon
alt={pricing.productIconAlt}
src={pricing.productIconSrc}
/>
</ProductHeader>
</ProductBlock>
<Separator aria-hidden="true" />
</SummaryInner>
</SummaryPad>
</StickyHeader>
<ContentPad>
<Inner>
<ProductBlock>
<ProductTitle>{pricing.productTitle}</ProductTitle>
<PriceRow>
<PriceAmount>{pricing.priceAmount}</PriceAmount>
<PriceSuffix>{pricing.priceSuffix}</PriceSuffix>
</PriceRow>
</ProductBlock>
<FakeButton>{pricing.primaryCtaLabel}</FakeButton>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
{pricing.addons.map((addon) => {
const checked = checkedIds.has(addon.id);
return (
<AddonRow key={addon.id}>
<CheckboxLabel disabled={addon.disabled}>
<CheckboxLabel
disabled={addon.disabled}
ref={(node) => {
addonAnchorRefs.current[addon.id] = node;
}}
>
<HiddenCheckbox
checked={checked}
disabled={addon.disabled}
onChange={() => onAddonToggle(addon)}
onChange={() =>
onAddonToggle(
addon,
addonAnchorRefs.current[addon.id]?.getBoundingClientRect() ??
null,
)
}
type="checkbox"
/>
<CheckboxFace checked={checked} aria-hidden="true">
@@ -236,11 +639,24 @@ export function PricingWindow({
</CheckboxFace>
<AddonLabelText>{addon.label}</AddonLabelText>
</CheckboxLabel>
<AddonRightText>{addon.rightLabel}</AddonRightText>
<AddonRightText>
{addon.rightLabelParts
? renderRightLabelParts(addon.rightLabelParts)
: renderRightLabel(addon.rightLabel)}
</AddonRightText>
</AddonRow>
);
})}
<FakeButton>{pricing.secondaryCtaLabel}</FakeButton>
{pricing.secondaryCtaNote ? (
<FooterNote>{pricing.secondaryCtaNote}</FooterNote>
) : null}
<FakeButton
href={pricing.secondaryCtaHref}
rel="noreferrer"
target="_blank"
>
{pricing.secondaryCtaLabel}
</FakeButton>
</Inner>
</ContentPad>
</Panel>

View File

@@ -2,10 +2,18 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useState } from 'react';
const POPUP_WIDTH = 321;
export const WRONG_CHOICE_POPUP_WIDTH = 321;
const POPUP_VISIBLE_DURATION_MS = 3000;
const POPUP_FADE_DURATION_MS = 240;
const Shell = styled.div<{ stackIndex: number }>`
const Shell = styled.div<{
isClosing: boolean;
layerIndex: number;
left: number;
top: number;
}>`
background-color: #c0c0c0;
box-shadow:
inset -1px -1px 0 0 #0a0a0a,
@@ -14,17 +22,20 @@ const Shell = styled.div<{ stackIndex: number }>`
inset 2px 2px 0 0 #ffffff;
display: flex;
flex-direction: column;
left: ${({ stackIndex }) => 24 + stackIndex * 18}px;
left: ${({ left }) => left}px;
opacity: ${({ isClosing }) => (isClosing ? 0 : 1)};
padding: 3px;
pointer-events: ${({ isClosing }) => (isClosing ? 'none' : 'auto')};
position: absolute;
top: ${({ stackIndex }) => 120 + stackIndex * 20}px;
width: ${POPUP_WIDTH}px;
z-index: ${({ stackIndex }) => 20 + stackIndex};
top: ${({ top }) => top}px;
transition: opacity ${POPUP_FADE_DURATION_MS}ms ease-out;
width: ${WRONG_CHOICE_POPUP_WIDTH}px;
z-index: ${({ layerIndex }) => 20 + layerIndex};
`;
const TitleBar = styled.div`
align-items: center;
background: linear-gradient(90deg, #c00000 0%, #b5b5b5 100%);
background: linear-gradient(90deg, #008000 0%, #b5b5b5 100%);
display: flex;
justify-content: space-between;
padding: 3px 2px 3px 3px;
@@ -32,8 +43,8 @@ const TitleBar = styled.div`
`;
const TitleText = styled.p`
color: ${theme.colors.secondary.background[100]};
font-family: ${theme.font.family.mono};
color: ${theme.colors.secondary.text[100]};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4)};
line-height: 12px;
margin: 0;
@@ -48,6 +59,9 @@ const CloseButton = styled.button`
inset -2px -2px 0 0 #808080,
inset 2px 2px 0 0 #dfdfdf;
cursor: pointer;
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(4)};
line-height: 1;
padding: 3px 4px 4px;
position: relative;
@@ -67,16 +81,16 @@ const BodyRow = styled.div`
`;
const IconMark = styled.span`
color: #c00000;
color: #008000;
flex-shrink: 0;
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(8)};
line-height: 1;
`;
const BodyText = styled.p`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.mono};
font-family: ${theme.font.family.retro};
font-size: ${theme.font.size(5)};
line-height: ${theme.spacing(6)};
margin: 0;
@@ -84,30 +98,71 @@ const BodyText = styled.p`
export type WrongChoicePopupProps = {
body: string;
isClosingRequested?: boolean;
layerIndex: number;
left: number;
onClose: () => void;
stackIndex: number;
top: number;
titleBar: string;
titleId: string;
};
export function WrongChoicePopup({
body,
isClosingRequested = false,
layerIndex,
left,
onClose,
stackIndex,
top,
titleBar,
titleId,
}: WrongChoicePopupProps) {
const [isClosing, setIsClosing] = useState(false);
useEffect(() => {
const fadeTimer = window.setTimeout(() => {
setIsClosing(true);
}, POPUP_VISIBLE_DURATION_MS);
return () => {
window.clearTimeout(fadeTimer);
};
}, []);
useEffect(() => {
if (isClosingRequested) {
setIsClosing(true);
}
}, [isClosingRequested]);
useEffect(() => {
if (!isClosing) {
return;
}
const removeTimer = window.setTimeout(() => {
onClose();
}, POPUP_FADE_DURATION_MS);
return () => {
window.clearTimeout(removeTimer);
};
}, [isClosing, onClose]);
return (
<Shell
aria-labelledby={titleId}
isClosing={isClosing}
layerIndex={layerIndex}
left={left}
role="dialog"
stackIndex={stackIndex}
top={top}
>
<TitleBar>
<TitleText id={titleId}>{titleBar}</TitleText>
<CloseButton
aria-label="Close dialog"
onClick={onClose}
onClick={() => undefined}
type="button"
>
<span aria-hidden="true">×</span>

View File

@@ -1,5 +0,0 @@
export {
SALESFORCE_POPUPS,
type SalesforcePopupKey,
type SalesforceWrongChoicePopupType,
} from './salesforce-popups';

View File

@@ -1,17 +0,0 @@
export type SalesforceWrongChoicePopupType = {
body: string;
titleBar: string;
};
export const SALESFORCE_POPUPS = {
wrongChoiceDefault: {
body: 'Better than liquid glass !',
titleBar: 'Wrong choice',
},
wrongChoiceLorem: {
body: 'Lorem ipsum dolor sit amet',
titleBar: 'Wrong choice',
},
} as const satisfies Record<string, SalesforceWrongChoicePopupType>;
export type SalesforcePopupKey = keyof typeof SALESFORCE_POPUPS;

View File

@@ -1,24 +1,42 @@
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { SalesforcePopupKey } from '@/sections/Salesforce/constants';
export type SalesforceWrongChoicePopupType = {
body: string;
titleBar: string;
};
export type SalesforceRichTextPartType = {
strike?: boolean;
text: string;
};
export type SalesforceAddonRowType = {
cost: number;
defaultChecked?: boolean;
disabled?: boolean;
fixedCost?: number;
id: string;
label: string;
popupKey: SalesforcePopupKey;
netSpendRate?: number;
popup: SalesforceWrongChoicePopupType;
rightLabelParts?: SalesforceRichTextPartType[][];
rightLabel: string;
sharedCostKey?: string;
};
export type SalesforcePricingPanelType = {
basePriceAmount: number;
productIconAlt: string;
productIconSrc: string;
totalPriceLabel: string;
windowTitle: string;
productTitle: string;
priceAmount: string;
priceSuffix: string;
primaryCtaLabel: string;
featureSectionHeading: string;
addons: SalesforceAddonRowType[];
secondaryCtaNote?: string;
secondaryCtaHref: string;
secondaryCtaLabel: string;
};

View File

@@ -2,9 +2,6 @@ export type {
SalesforceAddonRowType,
SalesforceDataType,
SalesforcePricingPanelType,
} from './SalesforceData';
export type {
SalesforcePopupKey,
SalesforceRichTextPartType,
SalesforceWrongChoicePopupType,
} from '../constants';
} from './SalesforceData';

View File

@@ -3,6 +3,7 @@ export const font = {
sans: 'var(--font-sans), sans-serif',
serif: 'var(--font-serif), serif',
mono: 'var(--font-mono), monospace',
retro: 'var(--font-retro), monospace',
},
weight: { light: '300', regular: '400', medium: '500' },
size: (multiplier: number) => `calc(var(--font-base) * ${multiplier})`,