Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Pricing calculator #361

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/components/Pricing/Calculator/RangeInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*Range Reset*/
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
height: 2rem;
}

/***** Track Styles *****/
/***** Chrome, Safari, Opera, and Edge Chromium *****/
input[type='range']::-webkit-slider-runnable-track {
@apply rounded-full bg-gray-dark-3;
height: 1.2rem;
}

/******** Firefox ********/
input[type='range']::-moz-range-track {
@apply rounded-full bg-gray-dark-3;
height: 1.2rem;
}

/***** Thumb Styles *****/
/***** Chrome, Safari, Opera, and Edge Chromium *****/
input[type='range']::-webkit-slider-thumb {
@apply rounded-full bg-yellow;
margin-top: -0.4rem;
-webkit-appearance: none;
appearance: none;
height: 2rem;
width: 2rem;
}

/***** Firefox *****/
input[type='range']::-moz-range-thumb {
@apply rounded-full bg-yellow;
border: none; /*Removes extra border that FF applies*/
border-radius: 0; /*Removes default border-radius that FF applies*/
background-color: #5cd5eb;
height: 2rem;
width: 2rem;
}
324 changes: 324 additions & 0 deletions src/components/Pricing/Calculator/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { Button } from '@components/Button';
import ContentBox from '@components/ContentBox';
import Text from '@components/Text';
import './RangeInput.module.css';
import { useState, memo, useCallback } from 'react';

type RangeId = 'requests' | 'duration' | 'vcpu' | 'bandwidth';

type CalculatorTemplate = {
name: string;
values: Partial<Record<RangeId, number>>;
};

type RangeDefinition = {
id: RangeId;
name: string;
defaultValue: number;
formatter: (value: string) => string;
calculateCost: (value: number) => number;
min: number;
max: number;
increments: number;
};

type RangeState = {
currentValue?: number;
};

const templates: CalculatorTemplate[] = [
{
name: 'Hackathon project',
values: {
requests: 2_000,
duration: 12_000,
vcpu: 4_000,
bandwidth: 6_000,
},
},
{
name: 'Personal site',
values: {
requests: 5_000,
duration: 18_000,
vcpu: 7_000,
bandwidth: 9_000,
},
},
{
name: 'Growing SaaS',
values: {
requests: 9_000,
duration: 22_000,
vcpu: 11_000,
bandwidth: 13_000,
},
},
{
name: '"We got users"',
values: {
requests: 10_000,
duration: 28_000,
vcpu: 12_000,
bandwidth: 18_000,
},
},
];

const ranges: RangeDefinition[] = [
{
id: 'requests',
name: 'Requests per month',
formatter: (value: string) => `${value}k`,
calculateCost: (value) => value * 0,
min: 0,
defaultValue: 10,
max: 20_000,
increments: 2,
},
{
id: 'duration',
name: 'Duration (hours/month)',
formatter: (value) => `${value}hrs`,
calculateCost: (value) => value * 7.2,
min: 200,
defaultValue: 9_000,
max: 20_000,
increments: 2,
},
{
id: 'vcpu',
name: 'vCPU',
formatter: (value) => `${value}hrs`,
calculateCost: (value) => value * 0.00003,
min: 200,
defaultValue: 9_000,
max: 20_000,
increments: 2,
},
{
id: 'bandwidth',
name: 'Bandwidth (GB)',
formatter: (value) => `${value}GB`,
calculateCost: (value) => value * 0.05,
min: 200,
defaultValue: 9_000,
max: 20_000,
increments: 2,
},
];

type RangeInputProps = RangeDefinition &
RangeState & {
handleOnChange: (id: RangeId, value: number) => void;
};

const RangeInput = memo(
({
name,
formatter,
min,
max,
increments,
defaultValue,
id,
currentValue,
handleOnChange,
}: RangeInputProps) => {
return (
<div className="flex flex-col">
<label
htmlFor="input"
className="mb-5 flex w-full flex-row justify-between"
>
<Text style="s" as="span" className="text-ui-white">
{name}
</Text>
<Text style="s-mid" as="span" className="font-medium text-ui-white">
{formatter((currentValue ?? defaultValue).toString())}
</Text>
</label>
<input
type="range"
id={`${id}-range`}
min={min}
max={max}
step={increments}
value={currentValue}
onChange={(event) => handleOnChange(id, parseInt(event.target.value))}
/>
</div>
);
},
(prevProps, nextProps) => prevProps.currentValue === nextProps.currentValue,
);

const applyTemplate = (
template: CalculatorTemplate,
setRangeState: React.Dispatch<React.SetStateAction<Map<RangeId, RangeState>>>,
) => {
setRangeState((prevState) => {
const nextState = new Map(prevState);

for (const [id, value] of Object.entries(template.values)) {
if (nextState.has(id as RangeId)) {
nextState.set(id as RangeId, {
currentValue: value,
});
}
}

return nextState;
});
};

const initializeRangeState = (definitions: RangeDefinition[]) =>
new Map(
definitions.map((def) => [def.id, { currentValue: def.defaultValue }]),
);

const Invoice = ({ total }: { total: number }) => {
return (
<ContentBox
className="h-full"
contentClassName="h-full bg-neutral-2 justify-items-center flex flex-col"
variant="narrow"
>
<Text style="m-mid" className="text-neutral-12">
Example Invoice
</Text>

<div className="my-10 flex w-full flex-col gap-6 border border-x-transparent border-y-neutral-600 py-10">
<div className="flex w-full flex-row items-center justify-between">
<Text style="m-mid">From...</Text>
<img
src="/svg/fleek-logo.svg"
width={48}
height={18}
alt="fleek logo"
loading="lazy"
/>
</div>
<div className="flex w-full flex-row items-center justify-between">
<Text style="m-mid">To...</Text>
<Text style="m-mid" className="text-neutral-12">
Our favorite user
</Text>
</div>
</div>

<div className="flex w-full flex-grow flex-col items-center justify-center">
<Text as="span" style="s">
Estimated monthly cost
</Text>
<span className="flex flex-row items-baseline gap-6 text-yellow">
<Text as="span" style="m" className="font-medium">
$
</Text>
<Text style="2xl">{total.toFixed(2)}</Text>
<Text as="span" style="m" className="font-medium">
/mo
</Text>
</span>
</div>
</ContentBox>
);
};

const TemplateButton = memo(
({
template,
handleApplyTemplate,
}: {
template: CalculatorTemplate;
handleApplyTemplate: (template: CalculatorTemplate) => void;
}) => {
const onClick = useCallback(
() => handleApplyTemplate(template),
[template],
);

return (
<Button
onClick={onClick}
variant="secondary"
size="sm"
className="w-full"
>
{template.name}
</Button>
);
},
(prevProps, nextProps) => prevProps.template.name === nextProps.template.name,
);

const Calculator = () => {
const [rangeState, setRangeState] = useState<Map<RangeId, RangeState>>(() =>
initializeRangeState(ranges),
);

const total = ranges.reduce((acc, range) => {
const currentValue = rangeState.get(range.id)?.currentValue || 0;
return acc + range.calculateCost(currentValue);
}, 0);

const handleRangeChange = useCallback((id: RangeId, newValue: number) => {
setRangeState((prevState) => {
const nextState = new Map(prevState);
nextState.set(id, { ...prevState.get(id), currentValue: newValue });
return nextState;
});
}, []);

const handleApplyTemplate = (template: CalculatorTemplate) =>
applyTemplate(template, setRangeState);

return (
<>
<noscript>
<style type="text/css">{`#calculator { display: none; }`}</style>
<div className="noscriptmsg">You don't have javascript enabled :)</div>
</noscript>
<div id="calculator" className="flex flex-col gap-27 py-64">
<div className="gap-7">
<Text style="2xl" className="text-left text-neutral-12">
Pricing Calculator
</Text>
<Text style="m" className="text-left">
These costs are estimated based on similar projects hosted on Fleek.
</Text>
</div>

<ContentBox contentClassName="bg-neutral-1">
<div className="flex flex-col gap-20">
<div className="flex flex-row gap-10">
{templates.map((template) => (
<TemplateButton
template={template}
handleApplyTemplate={handleApplyTemplate}
key={template.name}
/>
))}
</div>
<div className="flex flex-row gap-25">
<div className="flex w-[60%] flex-col gap-20">
{ranges.map((props) => (
<RangeInput
{...props}
currentValue={rangeState.get(props.id)?.currentValue}
handleOnChange={handleRangeChange}
/>
))}
</div>
<div className="w-[40%]">
<Invoice total={total} />
</div>
</div>
</div>
</ContentBox>
</div>
</>
);
};

export default Calculator;
2 changes: 2 additions & 0 deletions src/components/Pricing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Accordion from '@components/Accordion';
import settings from '@base/settings.json';
import data from './data.json';
import { parseContentWithLinkPlaceholders } from '@utils/parseContentWithLinkPlaceholders';
import Calculator from './Calculator';

const resources = settings.support.resources || {};

Expand Down Expand Up @@ -34,6 +35,7 @@ const Pricing = () => {
return <PricingCard key={index} {...item} />;
})}
</div>
<Calculator />
<TableMobile />
<TableDesktop />

Expand Down
2 changes: 2 additions & 0 deletions src/components/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type TextStyle =
| 'm'
| 'm-mid'
| 's'
| 's-mid'
| 'xs'
// | "caption-text-m"
// | "caption-text-s"
Expand Down Expand Up @@ -53,6 +54,7 @@ const textStyles: Record<TextStyle, string> = {
'm-mid': 'text-13 font-plex-sans leading-[150%] lg:text-14 xl:text-16',
'm-strong': 'font-plex-sans text-16 leading-[150%] font-medium',
s: 'font-plex-sans text-10 font-medium leading-[150%] lg:text-13 lg:font-normal',
's-mid': 'font-plex-sans text-10 font-medium leading-[150%] lg:text-13',
xs: 'text-10 font-plex-sans font-medium leading-[125%] lg:text-12',
'caption-l':
'font-plex-sans text-16 tracking-[0.32rem] font-medium leading-[150%] uppercase lg:tracking-[0.4rem] lg:text-20',
Expand Down
Loading