new pricing plans table (#6581)

This commit is contained in:
Piotr Monwid-Olechnowicz 2025-03-11 00:10:09 +01:00 committed by GitHub
parent 06e7012968
commit e638e6876d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 688 additions and 116 deletions

View file

@ -231,6 +231,7 @@ module.exports = {
'nextra-scrollbar',
'no-scrollbar', // from Nextra
'hive-slider',
'subheader',
],
config: path.join(__dirname, './packages/web/docs/tailwind.config.ts'),
},

View file

@ -28,7 +28,7 @@
},
"devDependencies": {
"@tailwindcss/typography": "0.5.16",
"@theguild/tailwind-config": "0.6.2",
"@theguild/tailwind-config": "0.6.3",
"@types/react": "18.3.18",
"@types/rss": "^0.0.32",
"next-sitemap": "4.2.3",

View file

@ -2,7 +2,6 @@
import { use } from 'react';
import { cn, ComparisonTable as Table } from '@theguild/components';
import { functionalTones } from './functional-tones';
import { CheckmarkIcon, XIcon } from './icons';
interface BenchmarkDatum {
@ -47,48 +46,35 @@ export function BenchmarkTableBody() {
>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<div
className="size-3 rounded-full"
style={{
background:
compatibility > 99
? functionalTones.positiveBright
: compatibility > 90
? functionalTones.warning
: functionalTones.criticalBright,
}}
className={cn(
'size-3 rounded-full',
compatibility > 99
? 'bg-positive-bright'
: compatibility > 90
? 'bg-warning-bright'
: 'bg-critical-bright',
)}
/>
{row.name}
</div>
</Table.Cell>
<Table.Cell className="text-sm text-green-800">{compatibility.toFixed(2)}%</Table.Cell>
<Table.Cell>
<span
className="inline-flex items-center gap-0.5 text-sm"
style={{ color: functionalTones.positiveDark }}
>
<span className="text-positive-dark inline-flex items-center gap-0.5 text-sm">
<CheckmarkIcon className="size-4" /> {row.cases.passed}
</span>
{row.cases.failed > 0 && (
<span
className="ml-2 inline-flex items-center text-sm"
style={{ color: functionalTones.criticalDark }}
>
<span className="text-critical-dark ml-2 inline-flex items-center text-sm">
<XIcon className="size-4" /> {row.cases.failed}
</span>
)}
</Table.Cell>
<Table.Cell>
<span
className="inline-flex items-center gap-0.5 text-sm"
style={{ color: functionalTones.positiveDark }}
>
<span className="text-positive-dark inline-flex items-center gap-0.5 text-sm">
<CheckmarkIcon className="size-4" /> {row.suites.passed}
</span>
{row.suites.failed > 0 && (
<span
className="ml-2 inline-flex items-center text-sm"
style={{ color: functionalTones.criticalDark }}
>
<span className="text-critical-dark ml-2 inline-flex items-center text-sm">
<XIcon className="size-4" /> {row.suites.failed}
</span>
)}

View file

@ -1,10 +0,0 @@
/**
* todo: move this to the design system as Tailwind classes
*/
export const functionalTones = {
criticalBright: '#FD3325',
criticalDark: ' #F81202',
warning: '#FE8830',
positiveBright: '#24D551',
positiveDark: '#1BA13D',
};

View file

@ -1,7 +1,6 @@
import { Suspense } from 'react';
import { CallToAction, cn, Heading, ComparisonTable as Table } from '@theguild/components';
import { BenchmarkTableBody } from './benchmark-table-body';
import { functionalTones } from './functional-tones';
import { CheckmarkIcon, XIcon } from './icons';
export interface FederationCompatibleBenchmarksSectionProps
@ -90,26 +89,22 @@ function BenchmarkLegend() {
<div className="mt-6 flex flex-wrap gap-2 whitespace-nowrap text-xs text-green-800 sm:gap-4">
<div className="flex gap-2 max-sm:-mx-1 max-sm:w-full sm:contents">
<div className="flex items-center gap-1">
<CheckmarkIcon className="size-4" style={{ color: functionalTones.positiveDark }} />{' '}
Passed tests
<CheckmarkIcon className="text-positive-dark size-4" /> Passed tests
</div>
<div className="flex items-center gap-1">
<XIcon className="size-4" style={{ color: functionalTones.criticalDark }} /> Failed tests
<XIcon className="text-critical-dark size-4" /> Failed tests
</div>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ background: functionalTones.positiveBright }}
/>
<div className="bg-positive-bright size-2 rounded-full" />
Perfect compatibility
</div>
<div className="flex items-center gap-2">
<div className="size-2 rounded-full" style={{ background: functionalTones.warning }} />
<div className="bg-warning-bright size-2 rounded-full" />
75% and higher
</div>
<div className="flex items-center gap-2">
<div className="size-2 rounded-full" style={{ background: functionalTones.criticalDark }} />
<div className="bg-critical-dark size-2 rounded-full" />
Less than 75%
</div>
</div>

View file

@ -17,6 +17,7 @@ import { DynamicMetaTags } from './dynamic-meta-tags';
import graphQLConfLocalImage from '../components/graphql-conf-image.webp';
import '@theguild/components/style.css';
import '../selection-styles.css';
import '../easing-functions.css';
import '../mermaid.css';
import { NarrowPages } from './narrow-pages';

View file

@ -10,6 +10,7 @@ import { FrequentlyAskedQuestions } from '../../components/frequently-asked-ques
import { LandingPageContainer } from '../../components/landing-page-container';
import { PlanComparison } from '../../components/plan-comparison';
import { Pricing } from '../../components/pricing';
import { PlansTable } from '../../components/pricing/plans-table';
export const metadata = {
title: 'Hive Platform Pricing',
@ -26,6 +27,8 @@ export default function PricingPage() {
<PlanComparison className="mx-4 md:mx-6" />
<PlansTable />
<CompanyTestimonialsSection className="mx-4 mt-6 md:mx-6" />
<FrequentlyAskedQuestions className="mx-4 md:mx-6" />

View file

@ -167,7 +167,7 @@ export function CompanyTestimonialsSection({ className }: { className?: string }
value={company}
tabIndex={-1}
className={cn(
'relative flex w-full shrink-0 snap-center flex-col',
'relative flex w-full shrink-0 snap-center flex-col outline-none',
'gap-6 md:flex-row lg:gap-12',
'lg:data-[state="inactive"]:hidden',
caseStudyHref

View file

@ -0,0 +1,17 @@
// these are different than CheckIcon and CloseIcon we have in the design system
export function CheckmarkIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M6.66668 10.1134L12.7947 3.98608L13.7373 4.92875L6.66668 11.9994L2.42401 7.75675L3.36668 6.81408L6.66668 10.1134Z" />
</svg>
);
}
export function XIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M7.99999 7.05806L11.3 3.75806L12.2427 4.70072L8.94266 8.00072L12.2427 11.3007L11.2993 12.2434L7.99932 8.94339L4.69999 12.2434L3.75732 11.3001L7.05732 8.00006L3.75732 4.70006L4.69999 3.75872L7.99999 7.05806Z" />
</svg>
);
}

View file

@ -0,0 +1,137 @@
'use client';
import { ReactNode, useEffect, useRef } from 'react';
const BOTTOM_THRESHOLD_ADJUSTMENT = 10;
interface NestedStickyProps {
children: ReactNode;
offsetTop: number;
offsetBottom: number;
zIndex?: number;
}
/**
* `position: sticky` doesn't work in nested divs with overflow-x-hidden,
* and restructuring the app to put pricing table header on top level would
* require tricky state management, so we have this for the cases where we
* need position: sticky, but can't use it directly.
*/
export function NestedSticky({
children,
offsetTop,
offsetBottom,
zIndex = 10,
}: NestedStickyProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const placeholder = container.firstElementChild as HTMLElement;
const sticky = container.lastElementChild as HTMLElement;
const parent = container.parentElement;
if (!placeholder || !sticky || !parent) return;
let width = 0;
let height = 0;
let rafId: number | null = null;
// relative at the top
// fixed when we scroll
// absolute when we're near the bottom
type State = 'fixed' | 'absolute' | 'relative';
let state: State = 'relative';
let prevState: State = 'relative';
const measureDimensions = () => {
const rect = sticky.getBoundingClientRect();
width = rect.width;
height = rect.height;
sticky.style.zIndex = String(zIndex);
};
const updateStyles = () => {
placeholder.style.height = state !== 'relative' ? `${height}px` : '0';
if (state === 'fixed') {
sticky.style.position = 'fixed';
sticky.style.top = `${offsetTop}px`;
sticky.style.width = `${width}px`;
sticky.setAttribute('data-sticky', 'fixed');
} else if (state === 'absolute') {
const containerRect = container.getBoundingClientRect();
const stickyRect = sticky.getBoundingClientRect();
const relativeTop = stickyRect.top - containerRect.top;
sticky.style.position = 'absolute';
sticky.style.top = `${relativeTop}px`;
sticky.style.width = `${width}px`;
sticky.setAttribute('data-sticky', 'absolute');
} else {
sticky.style.position = 'relative';
sticky.style.top = '';
sticky.style.width = '';
sticky.removeAttribute('data-sticky');
}
};
const handleScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const containerRect = container.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const shouldBeFixed = containerRect.top < offsetTop;
const nearBottom =
parentRect.bottom < offsetTop + height + offsetBottom + BOTTOM_THRESHOLD_ADJUSTMENT;
state = shouldBeFixed && nearBottom ? 'absolute' : shouldBeFixed ? 'fixed' : 'relative';
if (state !== prevState) {
prevState = state;
updateStyles();
}
});
};
const handleResize = () => {
const placeholderRect = placeholder.getBoundingClientRect();
width = placeholderRect.width;
updateStyles();
handleScroll();
};
measureDimensions();
handleScroll();
window.addEventListener('resize', handleResize, { passive: true });
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll);
};
}, [offsetTop, offsetBottom, zIndex]);
return (
<div ref={containerRef} className="relative">
<div style={{ width: '100%', height: 0 }} />
<div>{children}</div>
</div>
);
}

View file

@ -289,8 +289,9 @@ export function Pricing({ className }: { className?: string }): ReactElement {
}
callToAction={
<ContactButton variant="primary">
Shape a custom plan
<span className="hidden sm:inline">for your business</span>
<span>
Shape a custom plan <span className="hidden sm:inline">for your business</span>
</span>
</ContactButton>
}
features={

View file

@ -0,0 +1,441 @@
'use client';
import { ReactNode, useState } from 'react';
import {
CallToAction,
cn,
Heading,
ComparisonTable as Table,
TextLink,
} from '@theguild/components';
import { CheckmarkIcon, XIcon } from '../../app/gateway/federation-compatible-benchmarks/icons';
import { NestedSticky } from '../nested-sticky';
import {
AvailabilityIcon,
EnterpriseSupportIcon,
OperationsIcon,
SSOIcon,
UsageIcon,
} from './icons';
type PlanName = 'Hobby' | 'Pro' | 'Enterprise';
interface PricingPlan {
name: PlanName;
cta: ReactNode;
}
const pricingTiers: PricingPlan[] = [
{
name: 'Hobby',
cta: (
<CallToAction
variant="tertiary"
// todo: move this style as size="sm" to design system
className="px-3 py-2 text-sm"
href="https://app.graphql-hive.com"
>
Get started for free
</CallToAction>
),
},
{
name: 'Pro',
cta: (
<CallToAction
variant="primary"
className="px-3 py-2 text-sm"
href="https://app.graphql-hive.com"
>
Try free for 30 days
</CallToAction>
),
},
{
name: 'Enterprise',
cta: (
<CallToAction
variant="primary"
className="px-3 py-2 text-sm"
href="https://the-guild.dev/contact"
>
Shape your business
</CallToAction>
),
},
];
export function PlansTable({ className }: { className?: string }) {
const [activePlan, setActivePlan] = useState<PlanName>('Hobby');
const NO = <XIcon className="text-critical-dark mx-auto size-6" />;
const YES = <CheckmarkIcon className="text-positive-dark mx-auto size-6" />;
return (
<section className={cn('relative p-4 py-12 md:px-6 lg:py-24 xl:px-[120px]', className)}>
<Heading
size="md"
as="h3"
className="text-pretty text-center max-md:text-[32px]/10 max-md:tracking-[-.16px]"
>
Hive Console allows you to do so much more.
<br className="max-xl:hidden" /> On&nbsp;your&nbsp;own&nbsp;terms.
</Heading>
<p className="mb-8 mt-4 text-center md:mb-16">
Part of the Hive ecosystem, Hive Console is a fully-fledged solution that you can easily
tailor to your&nbsp;needs.
</p>
<MobileNavbar setActivePlan={setActivePlan} activePlan={activePlan} />
<div className="md:nextra-scrollbar md:-mx-6 md:overflow-x-auto md:px-6">
<NestedSticky offsetTop={80} offsetBottom={90}>
<div
aria-hidden
className="bg-beige-100 [[data-sticky]>&]:border-beige-200 relative flex items-center rounded-3xl border border-transparent *:text-left max-md:hidden md:*:w-1/4 [[data-sticky]>&]:rounded-t-none [[data-sticky]>&]:shadow-sm"
>
<div className="z-10 rounded-l-3xl p-6 text-xl/6 font-normal">Features</div>
{pricingTiers.map(tier => (
<div className="py-6 last:rounded-r-3xl" key={tier.name}>
<div className="border-beige-400 flex items-center justify-between gap-4 border-l px-6 sm:[@media(width<1400px)]:[&>a]:hidden">
<div className="text-xl/6 font-medium">{tier.name}</div>
{tier.cta}
</div>
</div>
))}
</div>
</NestedSticky>
<Table className="table w-full border-separate border-spacing-0 border-none">
<thead className="sr-only">
<tr>
<th>Features</th>
{pricingTiers.map(tier => (
<th key={tier.name}>{tier.name}</th>
))}
</tr>
</thead>
<tbody>
<TableSubheaderRow
icon={<OperationsIcon />}
title="Operations and data retention"
description="Structured by your plan—analyze the limits, manage your potential."
/>
<tr>
<PlansTableCell className="whitespace-pre">Operations per month</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
Limit of 100 operations
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
1M operations per month
<br className="max-sm:inline" />
Then $10 per million operations
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
Custom operation limit
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>Usage data retention</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
7 days
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
90 days
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
One-year Minimum, Customizable
</PlansTableCell>
</tr>
<TableSubheaderRow
icon={<UsageIcon />}
title="Usage"
description="All plans, all features, all unlimited. Know exactly what you're working with."
/>
<tr>
<PlansTableCell>Scale: projects and organizations</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
Unlimited
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
Unlimited
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
Unlimited
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>GitHub issues and chat support</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>Schema pushes and checks</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
Unlimited
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
Unlimited
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
Unlimited
</PlansTableCell>
</tr>
<TableSubheaderRow
icon={<AvailabilityIcon />}
title="Availability"
description="Engineered for uninterrupted performance and reliability."
/>
<tr>
<PlansTableCell>99.95% uptime of operation collection</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell className="lg:whitespace-pre">
100% uptime of schema registry CDN
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<TableSubheaderRow
icon={<SSOIcon />}
title="SSO"
description={
<>
Single sign-on via Open ID provider.{' '}
<TextLink href="/docs/management/sso-oidc-provider">Learn more.</TextLink>
</>
}
/>
<tr>
<PlansTableCell>Single sign-on via Open ID provider</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{YES}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<TableSubheaderRow
icon={<EnterpriseSupportIcon />}
title="Enterprise Support"
description="Dedicated resources and personalized guidance designed for enterprise-scale needs."
/>
<tr>
<PlansTableCell>Dedicated Slack channel for support</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>White-glove onboarding</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>Support SLA</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
<TextLink
href="https://the-guild.dev/graphql/hive/sla.pdf"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-800"
>
Pre-defined SLA
</TextLink>
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
<TextLink
href="https://the-guild.dev/graphql/hive/sla.pdf"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-800"
>
Pre-defined SLA
</TextLink>
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
Tailored to your needs
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>Technical Account Manager & guidance from The Guild</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>
Flexible billing options & extended procurement processes
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
<tr>
<PlansTableCell>Custom Data Processing Agreements (DPA)</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Hobby">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Pro">
{NO}
</PlansTableCell>
<PlansTableCell activePlan={activePlan} plan="Enterprise">
{YES}
</PlansTableCell>
</tr>
</tbody>
</Table>
</div>
</section>
);
}
function MobileNavbar({
setActivePlan,
activePlan,
}: {
setActivePlan: (plan: PlanName) => void;
activePlan: PlanName;
}) {
return (
<NestedSticky
offsetTop={
// --nextra-navbar-height
64
}
offsetBottom={482}
>
<div className="bg-beige-100 before:bg-beige-100 before:border-b-beige-400 relative top-0 z-10 w-full rounded-2xl p-2 duration-100 ease-[var(--hive-ease-overshoot-a-bit)] before:absolute before:inset-0 before:opacity-0 before:transition md:hidden [[data-sticky]>&:before]:scale-x-125 [[data-sticky]>&:before]:border-b [[data-sticky]>&:before]:opacity-100 [[data-sticky]>&:before]:shadow-sm">
<div className="relative flex w-full">
{pricingTiers.map(tier => (
<button
key={tier.name}
onClick={() => setActivePlan(tier.name)}
className={cn(
'hive-focus bg-beige-100 flex-1 rounded-xl px-3 py-2 text-center text-sm font-medium leading-5 transition hover:z-10 hover:ring hover:ring-inset focus:z-10',
activePlan === tier.name && 'bg-white',
)}
>
{tier.name}
</button>
))}
</div>
<div className="relative mt-3 h-9">
{pricingTiers.map((plan, i) => {
const isActive = plan.name === activePlan;
return (
<div
className={cn(
'absolute inset-0 z-10 flex items-center justify-center rounded-lg *:!w-full aria-hidden:pointer-events-none aria-hidden:z-0',
i === 0 && 'bg-beige-100',
)}
aria-hidden={!isActive}
key={plan.name}
>
{plan.cta}
</div>
);
})}
</div>
</div>
</NestedSticky>
);
}
function PlansTableCell({
plan,
activePlan: currentPlan,
children,
className,
}: {
plan?: PlanName;
activePlan?: PlanName;
children: ReactNode;
className?: string;
}) {
return (
<td
aria-hidden={plan !== currentPlan}
className={cn(
'border-beige-400 border-b border-r p-4 first:border-l first:font-medium max-md:w-1/2 max-sm:text-sm sm:py-6 md:w-1/4 [&:not(:first-child)]:border-l-0 [&:not(:first-child)]:text-center [&:not(:first-child)]:text-sm [&:not(:first-child)]:text-green-800 md:[.subheader+tr>&:last-child]:rounded-tr-3xl max-md:[.subheader+tr>&:not(:first-child,:has(+td[aria-hidden=false]))]:rounded-tr-3xl [.subheader+tr>&]:border-t [.subheader+tr>&]:first:rounded-tl-3xl md:[tr:is(:has(+.subheader),:last-child)>&:last-child]:rounded-br-3xl max-md:[tr:is(:has(+.subheader),:last-child)>&:not(:first-child,:has(+td[aria-hidden=false]))]:rounded-br-3xl [tr:is(:last-child,:has(+.subheader))>&]:first:rounded-bl-3xl',
plan && plan !== currentPlan && 'max-md:hidden',
className,
)}
>
{children}
</td>
);
}
interface TableSubheaderRowProps {
icon: ReactNode;
title: string;
description: ReactNode;
}
function TableSubheaderRow({ icon, title, description }: TableSubheaderRowProps) {
return (
<tr className="subheader">
<td colSpan={4} className="pb-6 pt-8">
<div className="flex items-center text-[32px]/10 max-md:text-[20px]/6 max-md:font-medium [&>svg]:m-[6.67px] [&>svg]:mr-[10.67px] [&>svg]:size-[26.67px] [&>svg]:text-green-600">
{icon}
{title}
</div>
<p className="mt-2 text-green-800">{description}</p>
</td>
</tr>
);
}

View file

@ -16,32 +16,33 @@ export function PricingSlider({
const max = 300;
const [popoverOpen, setPopoverOpen] = useState(false);
const rootRef = useRef<HTMLLabelElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
return (
<label
<div
ref={rootRef}
className={cn(
'relative isolate block select-none rounded-3xl border border-green-400 p-4 [counter-set:ops_calc(var(--ops))_price_calc(var(--price))] sm:p-8',
'relative isolate block select-none rounded-3xl border border-green-400 p-4 [counter-set:ops_calc(var(--ops))] sm:p-8',
className,
)}
// 10$ base price + 10$ per 1M
style={{ '--ops': min, '--price': 'calc(10 + var(--ops) * 10)' }}
{...rest}
>
<div className="text-green-1000 items-center text-2xl font-medium md:flex md:h-12 md:w-[calc(100%-260px)]">
<div className="relative max-w-[clamp(calc(60.95px+14.47px*round(down,log(max(var(--ops),1),10),1)),(2-var(--ops))*111px,111px)] shrink grow motion-safe:transition-all">
<div
aria-hidden
className="flex w-full whitespace-pre rounded-[40px] bg-blue-300 px-3 py-1 tabular-nums leading-8 opacity-[calc(var(--ops)-1)] [transition-duration:calc(clamp(0,var(--ops)-1,1)*350ms)] before:tracking-[-0.12em] before:content-[''_counter(ops)_'_'] motion-safe:transition-all"
>
<div
aria-hidden
className="text-green-1000 flex flex-wrap items-center text-2xl font-medium md:h-12 md:w-[calc(100%-260px)]"
>
<div className="relative min-w-[clamp(calc(60.95px+14.47px*round(down,log(max(var(--ops),1),10),1)),(2-var(--ops))*111px,111px)] max-w-[clamp(calc(60.95px+14.47px*round(down,log(max(var(--ops),1),10),1)),(2-var(--ops))*111px,111px)] shrink grow motion-safe:transition-all">
<div className="flex w-full whitespace-pre rounded-[40px] bg-blue-300 px-3 py-1 tabular-nums leading-8 opacity-[calc(var(--ops)-1)] [transition-duration:calc(clamp(0,var(--ops)-1,1)*350ms)] before:tracking-[-0.12em] before:content-[''_counter(ops)_'_'] motion-safe:transition-all">
M
</div>
<div className="absolute left-0 top-0 whitespace-pre leading-10 opacity-[calc(2-var(--ops))] [transition-duration:calc(clamp(0,2-var(--ops),1)*350ms)] motion-safe:transition">
How many
</div>
</div>
<div className="whitespace-pre"> operations per month </div>
<div className="shrink-0 whitespace-pre"> operations </div>
<div className="whitespace-pre [@media(width<900px)]:hidden">per month </div>
<div className="whitespace-pre opacity-[calc(2-var(--ops))] [transition-duration:350ms] motion-safe:transition">
do you need?
</div>
@ -50,12 +51,13 @@ export function PricingSlider({
<div className="text-green-1000 flex items-center gap-5 pt-12 text-sm">
<span className="font-medium">{min}M</span>
<Slider
aria-label="How many operations per month do you need?"
deadZone="16px"
min={min}
max={max}
step={1}
defaultValue={min}
counter="after:content-['$'_counter(price)_'_/_month']"
counter="after:content-['$'_counter(price)_'_/_month'] after:[counter-set:price_calc(var(--price))]"
onChange={event => {
const value = event.currentTarget.valueAsNumber;
rootRef.current!.style.setProperty('--ops', `${value}`);
@ -92,6 +94,6 @@ export function PricingSlider({
(assuming no sampling).
</Content>
</Root>
</label>
</div>
);
}

View file

@ -89,58 +89,15 @@ export function Slider({ counter, className, deadZone, style, ...rest }: SliderP
}
@supports not (font: -apple-system-body) {
{/* todo: move to @theme in Tailwind 4 */}
.hive-slider {
--ease-overshoot-far: linear(
0 0%,
0.5007 7.21%,
0.7803 12.29%,
0.8883 14.93%,
0.9724 17.63%,
1.0343 20.44%,
1.0754 23.44%,
1.0898 25.22%,
1.0984 27.11%,
1.1014 29.15%,
1.0989 31.4%,
1.0854 35.23%,
1.0196 48.86%,
1.0043 54.06%,
0.9956 59.6%,
0.9925 68.11%,
1 100%
);
--ease-overshoot-a-bit: linear(
0 0%,
0.5007 7.21%,
0.7803 12.29%,
0.8883 14.93%,
0.9724 17.63%,
1.011319 20.44%,
1.024882 23.44%,
1.029634 25.22%,
1.032472 27.11%,
1.033462 29.15%,
1.032637 31.4%,
1.028182 35.23%,
1.006468 48.86%,
1.001419 54.06%,
0.9956 59.6%,
0.9925 68.11%,
1 100%
);
}
div,
div:after {
transition: transform var(--ease-overshoot-far) 500ms;
transition: transform var(--hive-ease-overshoot-far) 500ms;
}
@container (width >= 512px) {
div,
div:after {
transition: transform var(--ease-overshoot-a-bit) 500ms;
transition: transform var(--hive-ease-overshoot-a-bit) 500ms;
}
}
}
@ -153,7 +110,7 @@ export function Slider({ counter, className, deadZone, style, ...rest }: SliderP
) : (
<div className="flex w-full">
<button
className="z-10"
className="z-10 my-3"
style={{ width: deadZone }}
onClick={event => {
const input = event.currentTarget.parentElement!.querySelector(

View file

@ -0,0 +1,41 @@
:root {
--hive-ease-overshoot-far: linear(
0 0%,
0.5007 7.21%,
0.7803 12.29%,
0.8883 14.93%,
0.9724 17.63%,
1.0343 20.44%,
1.0754 23.44%,
1.0898 25.22%,
1.0984 27.11%,
1.1014 29.15%,
1.0989 31.4%,
1.0854 35.23%,
1.0196 48.86%,
1.0043 54.06%,
0.9956 59.6%,
0.9925 68.11%,
1 100%
);
--hive-ease-overshoot-a-bit: linear(
0 0%,
0.5007 7.21%,
0.7803 12.29%,
0.8883 14.93%,
0.9724 17.63%,
1.011319 20.44%,
1.024882 23.44%,
1.029634 25.22%,
1.032472 27.11%,
1.033462 29.15%,
1.032637 31.4%,
1.028182 35.23%,
1.006468 48.86%,
1.001419 54.06%,
0.9956 59.6%,
0.9925 68.11%,
1 100%
);
}

View file

@ -2038,7 +2038,7 @@ importers:
version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@theguild/components':
specifier: 9.3.4
version: 9.3.4(@theguild/tailwind-config@0.6.2(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.1.6(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.2.0(react@19.0.0))
version: 9.3.4(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.1.6(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.2.0(react@19.0.0))
date-fns:
specifier: 4.1.0
version: 4.1.0
@ -2068,8 +2068,8 @@ importers:
specifier: 0.5.16
version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
'@theguild/tailwind-config':
specifier: 0.6.2
version: 0.6.2(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
specifier: 0.6.3
version: 0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
'@types/react':
specifier: 18.3.18
version: 18.3.18
@ -7525,8 +7525,8 @@ packages:
'@theguild/remark-npm2yarn@0.3.2':
resolution: {integrity: sha512-H9T/GOuS/+4H7AY1cfD5DJIIIcGIIw1zMCB8OeTgXk7azJULsnuOurZ/CR54rvuTD+Krx0MVQccaUCvCWfP+vw==}
'@theguild/tailwind-config@0.6.2':
resolution: {integrity: sha512-dl0P3qDjj8Us0PSuPQKE+N1ZJXjuhr4uWi66pdTy24PdSNODFSBe7YgK87/UOfDUCDNnImRgt3Do6uAYsP46oA==}
'@theguild/tailwind-config@0.6.3':
resolution: {integrity: sha512-kmHHBnGRPiFtyBNuWCuKdC+kymh16OuFTD+R/Yb3yo+mNFJlOzGYdcu2ay6g1M+9+OlwSffifD2Zo97AZUwaSg==}
peerDependencies:
postcss-import: ^16.1.0
postcss-lightningcss: ^1.0.1
@ -23852,14 +23852,14 @@ snapshots:
typescript: 4.9.5
yargs: 16.2.0
'@theguild/components@9.3.4(@theguild/tailwind-config@0.6.2(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.1.6(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.2.0(react@19.0.0))':
'@theguild/components@9.3.4(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.1.6(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.2.0(react@19.0.0))':
dependencies:
'@giscus/react': 3.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@next/bundle-analyzer': 15.1.5
'@radix-ui/react-accordion': 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-icons': 1.3.2(react@19.0.0)
'@radix-ui/react-navigation-menu': 1.2.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@theguild/tailwind-config': 0.6.2(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
'@theguild/tailwind-config': 0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
clsx: 2.1.1
fuzzy: 0.1.3
next: 15.1.6(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -23955,7 +23955,7 @@ snapshots:
npm-to-yarn: 3.0.0
unist-util-visit: 5.0.0
'@theguild/tailwind-config@0.6.2(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))':
'@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))':
dependencies:
'@tailwindcss/container-queries': 0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)))
postcss-import: 16.1.0(postcss@8.4.49)