mirror of
https://github.com/twentyhq/twenty.git
synced 2026-05-25 00:45:27 -04:00
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:
committed by
GitHub
parent
c7d1cd11e0
commit
ea4ef99565
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
SALESFORCE_POPUPS,
|
||||
type SalesforcePopupKey,
|
||||
type SalesforceWrongChoicePopupType,
|
||||
} from './salesforce-popups';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ export type {
|
||||
SalesforceAddonRowType,
|
||||
SalesforceDataType,
|
||||
SalesforcePricingPanelType,
|
||||
} from './SalesforceData';
|
||||
|
||||
export type {
|
||||
SalesforcePopupKey,
|
||||
SalesforceRichTextPartType,
|
||||
SalesforceWrongChoicePopupType,
|
||||
} from '../constants';
|
||||
} from './SalesforceData';
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
Reference in New Issue
Block a user