From ea4ef995658a6a15071ea41b3fceb7a63a5c773d Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Tue, 7 Apr 2026 09:14:31 +0200 Subject: [PATCH] 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` --- .../images/pricing/salesforce/help-icon.png | Bin 0 -> 4245 bytes .../twenty-website-new/src/app/layout.tsx | 11 +- .../src/app/pricing/_constants/salesforce.ts | 170 +++++- .../sections/Menu/components/Root/Root.tsx | 4 + .../Salesforce/components/Flow/Flow.tsx | 182 +++++-- .../PricingWindow/PricingWindow.tsx | 496 ++++++++++++++++-- .../WrongChoicePopup/WrongChoicePopup.tsx | 87 ++- .../sections/Salesforce/constants/index.ts | 5 - .../Salesforce/constants/salesforce-popups.ts | 17 - .../Salesforce/types/SalesforceData.ts | 26 +- .../src/sections/Salesforce/types/index.ts | 7 +- packages/twenty-website-new/src/theme/font.ts | 1 + 12 files changed, 847 insertions(+), 159 deletions(-) create mode 100644 packages/twenty-website-new/public/images/pricing/salesforce/help-icon.png delete mode 100644 packages/twenty-website-new/src/sections/Salesforce/constants/index.ts delete mode 100644 packages/twenty-website-new/src/sections/Salesforce/constants/salesforce-popups.ts diff --git a/packages/twenty-website-new/public/images/pricing/salesforce/help-icon.png b/packages/twenty-website-new/public/images/pricing/salesforce/help-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4befdf9b08dea51869b3fe504de018c4089ebf29 GIT binary patch literal 4245 zcmcIn&59H;5Uvb^xGM{b2M?Zn5MiQ>?2h0f;z4hsFCciD9&A>D^NiIa^&WpP|3UcJqdao4s|hwr!iH!Lw~=Tdl}uvk_I$ z*89bFt;E1Spz3};MEF26P~FFgu3*6#62`lr{7c9RSmk<51_2=X~ooS^Yu5f+Pu;FKY_ zh6%Vdw82cxV2NG;ugwYj2!K{gc9 zW@BmUaq!@p>FqFpD*`G8UjM`Wq9TZM_ zg;XF8Ob|$F6G*CcqO%H325zt}u?7g@y(dUG4k;{t7o`&5xI5(yt};bV?>G~h)lI^xzTP%>iH`H0y_o$muT=SJs_5r1gL4YA_dzt z=a~AUkGUn`LqxG+ZOjS68;c?@M6U#*FerM%LnOGq>y7@&g%PCcub!rQ_3duXCR0Ci#Fk>7%zV^fR~Fl>}$a7+?xdl1P999)EL zTd7Oomx>7lh49wrKgLSGT4TRA?2zmaCSV}PB4T)XZ)e)GA4*M%PZvvrmpeV?n9fY> amf3vU-}(Ld&6Dq6saQR_T7G%>>f>Jp{syoB literal 0 HcmV?d00001 diff --git a/packages/twenty-website-new/src/app/layout.tsx b/packages/twenty-website-new/src/app/layout.tsx index 8c9e20aa986..88de054bbde 100644 --- a/packages/twenty-website-new/src/app/layout.tsx +++ b/packages/twenty-website-new/src/app/layout.tsx @@ -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 ( {children} diff --git a/packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts b/packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts index c59585d4756..0711b4549cc 100644 --- a/packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts +++ b/packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts @@ -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', }, }; diff --git a/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx index b00883a60d0..39971ac2cab 100644 --- a/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx +++ b/packages/twenty-website-new/src/sections/Menu/components/Root/Root.tsx @@ -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)` diff --git a/packages/twenty-website-new/src/sections/Salesforce/components/Flow/Flow.tsx b/packages/twenty-website-new/src/sections/Salesforce/components/Flow/Flow.tsx index bdede0352a6..2472ea53c0c 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/components/Flow/Flow.tsx +++ b/packages/twenty-website-new/src/sections/Salesforce/components/Flow/Flow.tsx @@ -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(null); + const popupSequenceRef = useRef(0); + const [isPricingWindowVisible, setIsPricingWindowVisible] = useState(true); const [checkedIds, setCheckedIds] = useState(() => { const initial = new Set(); for (const row of pricing.addons) { @@ -58,22 +104,51 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) { const [popups, setPopups] = useState([]); + 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) { - - - {popups.map((popup) => ( - { - handleClosePopup(popup.key); - }} - stackIndex={popup.stackIndex} - titleBar={popup.titleBar} - titleId={`sf-wrong-choice-${popup.key}`} + + {isPricingWindowVisible ? ( + - ))} + ) : null} + {isPricingWindowVisible + ? popups.map((popup) => ( + { + handleClosePopup(popup.key); + }} + top={popup.top} + titleBar={popup.titleBar} + titleId={`sf-wrong-choice-${popup.key}`} + /> + )) + : null} ); diff --git a/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow/PricingWindow.tsx b/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow/PricingWindow.tsx index f0c7e43c261..0d5a0223839 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow/PricingWindow.tsx +++ b/packages/twenty-website-new/src/sections/Salesforce/components/PricingWindow/PricingWindow.tsx @@ -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, +) => { + const appliedSharedCosts = new Set(); + + 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) => ( + 0}> + {line.map((part, partIndex) => ( + + {part.text} + + ))} + + )); + +const renderRightLabel = (label: string) => + label.split('\n').map((line, lineIndex) => ( + 0}> + {line} + + )); + export type PricingWindowProps = { checkedIds: ReadonlySet; - 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(null); + const stickyHeaderRef = useRef(null); + const addonAnchorRefs = useRef>({}); + const [stickyHeaderState, setStickyHeaderState] = useState({ + 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 ( - + diff --git a/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup/WrongChoicePopup.tsx b/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup/WrongChoicePopup.tsx index 6ccb2a060ed..c5c6657d022 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup/WrongChoicePopup.tsx +++ b/packages/twenty-website-new/src/sections/Salesforce/components/WrongChoicePopup/WrongChoicePopup.tsx @@ -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 ( {titleBar} undefined} type="button" > diff --git a/packages/twenty-website-new/src/sections/Salesforce/constants/index.ts b/packages/twenty-website-new/src/sections/Salesforce/constants/index.ts deleted file mode 100644 index c5ed2d71bad..00000000000 --- a/packages/twenty-website-new/src/sections/Salesforce/constants/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - SALESFORCE_POPUPS, - type SalesforcePopupKey, - type SalesforceWrongChoicePopupType, -} from './salesforce-popups'; diff --git a/packages/twenty-website-new/src/sections/Salesforce/constants/salesforce-popups.ts b/packages/twenty-website-new/src/sections/Salesforce/constants/salesforce-popups.ts deleted file mode 100644 index 3feb5751ad5..00000000000 --- a/packages/twenty-website-new/src/sections/Salesforce/constants/salesforce-popups.ts +++ /dev/null @@ -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; - -export type SalesforcePopupKey = keyof typeof SALESFORCE_POPUPS; diff --git a/packages/twenty-website-new/src/sections/Salesforce/types/SalesforceData.ts b/packages/twenty-website-new/src/sections/Salesforce/types/SalesforceData.ts index 05f16a39924..eadfc2b5d1c 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/types/SalesforceData.ts +++ b/packages/twenty-website-new/src/sections/Salesforce/types/SalesforceData.ts @@ -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; }; diff --git a/packages/twenty-website-new/src/sections/Salesforce/types/index.ts b/packages/twenty-website-new/src/sections/Salesforce/types/index.ts index 987a1c5be78..211598f3580 100644 --- a/packages/twenty-website-new/src/sections/Salesforce/types/index.ts +++ b/packages/twenty-website-new/src/sections/Salesforce/types/index.ts @@ -2,9 +2,6 @@ export type { SalesforceAddonRowType, SalesforceDataType, SalesforcePricingPanelType, -} from './SalesforceData'; - -export type { - SalesforcePopupKey, + SalesforceRichTextPartType, SalesforceWrongChoicePopupType, -} from '../constants'; +} from './SalesforceData'; diff --git a/packages/twenty-website-new/src/theme/font.ts b/packages/twenty-website-new/src/theme/font.ts index 67f39a8c4c5..eb80de47e09 100644 --- a/packages/twenty-website-new/src/theme/font.ts +++ b/packages/twenty-website-new/src/theme/font.ts @@ -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})`,