diff --git a/src/components/pricing/feature-comparison.tsx b/src/components/pricing/feature-comparison.tsx index 2f32444..57da52f 100644 --- a/src/components/pricing/feature-comparison.tsx +++ b/src/components/pricing/feature-comparison.tsx @@ -39,17 +39,17 @@ const GROUPS: FeatureGroup[] = [ { label: "Active contacts", tooltip: "The client records in your CRM — the people who can sign into your client experience.", - values: ["5", "50", "500", "Unlimited", "Custom"], + values: ["5", "50", "500", "Unlimited", "Unlimited"], }, { label: "Internal users included", tooltip: "Teammates with access to your workspace.", - values: ["1", "1", "3", "5", "Custom"], + values: ["1", "1", "3", "5", "Unlimited"], }, { label: "Cost per additional internal user", plain: true, - values: [false, false, "+$29 yr / $39 mo", "+$59 yr / $79 mo", "Custom"], + values: [false, false, "+$29 yr / $39 mo", "+$59 yr / $79 mo", false], }, ], }, @@ -139,6 +139,10 @@ const GROUPS: FeatureGroup[] = [ values: [false, false, false, true, true], }, { label: "Enforce MFA", values: [false, false, false, true, true] }, + { + label: "Advanced security controls", + values: [false, false, false, false, true], + }, { label: "HIPAA compliance with BAA", tooltip: "BAA available for handling protected health information.", @@ -215,6 +219,10 @@ const GROUPS: FeatureGroup[] = [ label: "Dedicated success manager", values: [false, false, false, false, true], }, + { + label: "Implementation service", + values: [false, false, false, false, true], + }, { label: "Technical advisor", values: [false, false, false, false, true], diff --git a/src/components/pricing/plan-wash.ts b/src/components/pricing/plan-wash.ts index 1b0a659..af1987f 100644 --- a/src/components/pricing/plan-wash.ts +++ b/src/components/pricing/plan-wash.ts @@ -38,12 +38,12 @@ export const PLAN_WASH_NEUTRAL = /** * /pricing only. Its cards are a subgrid that runs taller than the card block's, * so the wash is given a longer fade there; applied after the base strings - * above, which it overrides from lg up. + * above, which it overrides from xl up (where the table layout starts). */ export const PLAN_WASH_BRAND_LG = - "lg:[background:linear-gradient(to_bottom,rgba(125,164,255,0.45),transparent_160px)]"; + "xl:[background:linear-gradient(to_bottom,rgba(125,164,255,0.45),transparent_160px)]"; export const PLAN_WASH_NEUTRAL_LG = - "lg:[background:linear-gradient(to_bottom,var(--muted),transparent_160px)]"; + "xl:[background:linear-gradient(to_bottom,var(--muted),transparent_160px)]"; /** * The recommended card's own edge: brand blue at the top, gone by the middle, diff --git a/src/components/pricing/pricing-plans.tsx b/src/components/pricing/pricing-plans.tsx index 3f528f4..8cc5e9a 100644 --- a/src/components/pricing/pricing-plans.tsx +++ b/src/components/pricing/pricing-plans.tsx @@ -14,6 +14,11 @@ import { interface PlanFeatureGroup { label: string; items: string[]; + // This group lists what the plan adds on top of the tier below it. Its + // heading is worked out at render time ("Everything in Starter, plus") so it + // can fall back to plain "Features" when that tier's card isn't on screen — + // signed-in visitors don't see Free, so Starter has nothing to build on. + extendsPrevious?: boolean; } interface Plan { @@ -23,6 +28,13 @@ interface Plan { description: string; featureGroups: PlanFeatureGroup[]; cta: string; + ctaHref: string; + // A quieter "Book a demo" button under the main CTA, for the tiers where + // buying outright is the main path but a demo is a fair second. + secondaryDemo?: boolean; + // Price is a floor, not a rate: same figure on both billing toggles, and the + // line under it says so instead of "Billed monthly / annually". + startingPrice?: boolean; highlighted: boolean; } @@ -58,6 +70,7 @@ const PLANS: Plan[] = [ }, ], cta: "Get started", + ctaHref: SIGNUP_URL, highlighted: false, }, { @@ -75,7 +88,8 @@ const PLANS: Plan[] = [ ], }, { - label: "Everything in Free, plus", + label: "Features", + extendsPrevious: true, items: [ "MCP connector", "More included credits", @@ -88,6 +102,7 @@ const PLANS: Plan[] = [ }, ], cta: "Get started", + ctaHref: SIGNUP_URL, highlighted: false, }, { @@ -105,7 +120,8 @@ const PLANS: Plan[] = [ ], }, { - label: "Everything in Starter, plus", + label: "Features", + extendsPrevious: true, items: [ "Custom domains", "API access", @@ -121,6 +137,8 @@ const PLANS: Plan[] = [ }, ], cta: "Get started", + ctaHref: SIGNUP_URL, + secondaryDemo: true, highlighted: true, }, { @@ -138,7 +156,8 @@ const PLANS: Plan[] = [ ], }, { - label: "Everything in Pro, plus", + label: "Features", + extendsPrevious: true, items: [ "Audit log", "Client access permissions", @@ -155,7 +174,42 @@ const PLANS: Plan[] = [ ], }, ], + cta: "Get started", + ctaHref: SIGNUP_URL, + secondaryDemo: true, + highlighted: false, + }, + { + name: "Enterprise", + priceMonthly: 2000, + priceYearly: 2000, + description: "For firms with custom needs", + featureGroups: [ + { + label: "Fundamentals", + items: [ + "Unlimited active contacts", + "Unlimited internal users", + "Custom build credits", + ], + }, + { + label: "Features", + extendsPrevious: true, + items: ["Custom SSO", "Advanced security controls", "Sandbox workspace"], + }, + { + label: "Support", + items: [ + "Dedicated success manager", + "Implementation service", + "Shared Slack channel", + ], + }, + ], cta: DEMO_CTA_LABEL, + ctaHref: DEMO_URL, + startingPrice: true, highlighted: false, }, ]; @@ -166,70 +220,9 @@ const PAID_PLANS = PLANS.filter((plan) => plan.priceMonthly > 0); type Billing = "monthly" | "yearly"; -// Enterprise sits BELOW the tier columns as one wide card (so adding it never -// pushes the grid to five columns). Minimal: a label + one-line pitch, with the -// demo CTA on the right — no perk grid, so it reads as a quiet footnote to the -// tiers rather than a fifth plan. It's a standalone outlined card detached from -// the plans table above (gap, not a shared border) at every breakpoint. -function EnterpriseCard() { - return ( - // Same 4-column grid as the tiers so the heading, perks, and CTA line up - // under the columns above (heading | perks | perks | CTA). No dividers - // between them: the grid already does the separating, and four hairlines - // inside one row made it read as a second table under the real one. - // Keeps its fill on desktop dark, like the tier columns above it — dropped - // there, this card was the one row of the pricing table still sitting on the - // page's own near-black. -
- {/* Phones stack with tight spacing (the two perk groups read as one - list); from 560 — where the tiers above go two up — the perks split - into their two columns and the CTA stops running the full width of the - card; desktop is the 4-column grid with inset dividers. */} -
-
-

Enterprise

-
- {[ - ["Unlimited users, contacts, and apps", "Custom SSO"], - ["Volume-based build credits", "Dedicated architect & success manager"], - ].map((col, i) => ( - - ))} - {/* CTA column — solid dark fill, matching the tier buttons. */} -
- {/* Full width only on a phone. Across a two-column card it became a - 600px-wide button, which reads as a banner rather than a control. */} - - {DEMO_CTA_LABEL} - -
-
-
- ); -} - function planSubtitle(plan: Plan, billing: Billing) { if (plan.priceMonthly === 0) return "Free forever"; + if (plan.startingPrice) return "Starting price"; return billing === "yearly" ? "Billed annually" : "Billed monthly"; } @@ -265,13 +258,15 @@ function RollingDigit({ value }: { value: number }) { } function AnimatedPrice({ value }: { value: number }) { - const digits = String(value).split(""); + // Thousands separators as plain glyphs between the rolling digits, so $2,000 + // reads as one figure; the digits still roll, the comma just sits. + const chars = value.toLocaleString("en-US").split(""); return ( $ - {digits.map((d, i) => ( - - ))} + {chars.map((c, i) => + c === "," ? , : , + )} ); } @@ -280,7 +275,7 @@ function AnimatedPrice({ value }: { value: number }) { * One self-consistent grid of plan cards. * * Rendered twice — once with the Free tier and once without — because the cards - * style their outer corners with `lg:first:`/`lg:last:`. Hiding the Free card + * style their outer corners with `xl:first:`/`xl:last:`. Hiding the Free card * inside a single grid with CSS would leave `:first-child` matching a card that * isn't shown, so the rounded left edge would land on a hidden element and the * first visible card would square off. @@ -299,8 +294,11 @@ function PlanGrid({ // Two up from 560 rather than sm's 640: between those widths a single // column left the cards running the full width of the page with the // price stranded on one side of it. - className={`grid gap-6 min-[560px]:grid-cols-2 lg:gap-0 lg:grid-rows-[auto_auto_auto_auto_auto] ${ - plans.length === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4" + // The side-by-side table starts at xl, not lg: five columns need the + // width, and below it the cards run two up (the odd one out spans the + // row) rather than squeezing to ~180px each. + className={`grid gap-6 min-[560px]:grid-cols-2 xl:gap-0 xl:grid-rows-[auto_auto_auto_auto_auto] ${ + plans.length === 4 ? "xl:grid-cols-4" : "xl:grid-cols-5" } ${className}`} > {plans.map((plan, idx) => { @@ -308,6 +306,15 @@ function PlanGrid({ // The card AFTER the highlighted one drops its gray left divider, so // it doesn't double the highlighted card's blue right edge. const dropLeftDivider = plans[idx - 1]?.highlighted; + // Two-up (560–1279) with an odd number of cards leaves the last one + // alone in its row at half width. It takes the whole row instead, and + // from md its three groups sit side by side so it doesn't read as a + // stretched column. + const spansRow = plans.length % 2 === 1 && idx === plans.length - 1; + // The group heading: "Everything in Starter, plus" only while that + // card is actually on the page. Signed-in visitors don't see Free, so + // Starter has no card to build on and the heading is just "Features". + const prevName = plans[idx - 1]?.name; // Original structure kept: filled header panel (price pinned to the // bottom) → detached button → features. The only addition is an // outline around the WHOLE card (outer wrapper); the header panel no @@ -323,11 +330,13 @@ function PlanGrid({ // one table on the light page. In dark that left the whole table // on the page's near-black with nothing but hairlines to hold it, // so there the fill stays on at every width. - className={`relative isolate flex min-w-0 flex-col overflow-hidden rounded-2xl border border-border bg-card p-4 lg:grid lg:grid-rows-subgrid lg:row-span-5 lg:rounded-none lg:border-y lg:border-l lg:border-r-0 lg:border-solid lg:border-border lg:bg-transparent lg:[[data-theme=dark]_&]:bg-card lg:p-6 lg:first:!rounded-l-xl lg:last:!rounded-r-xl lg:[&:last-child]:border-r ${ - dropLeftDivider ? "lg:[[data-theme=dark]_&]:border-l-0" : "" + className={`${ + spansRow ? "min-[560px]:max-xl:col-span-2 " : "" + }relative isolate flex min-w-0 flex-col overflow-hidden rounded-2xl border border-border bg-card p-4 xl:grid xl:grid-rows-subgrid xl:row-span-5 xl:rounded-none xl:border-y xl:border-l xl:border-r-0 xl:border-solid xl:border-border xl:bg-transparent xl:[[data-theme=dark]_&]:bg-card xl:p-6 xl:first:!rounded-l-xl xl:last:!rounded-r-xl xl:[&:last-child]:border-r ${ + dropLeftDivider ? "xl:[[data-theme=dark]_&]:border-l-0" : "" } ${ plan.highlighted - ? `${PLAN_EDGE_BRAND} lg:[[data-theme=dark]_&]:border-r lg:[[data-theme=dark]_&]:[border-image:linear-gradient(to_bottom,rgba(125,164,255,0.55),#383838_75%)_1]` + ? `${PLAN_EDGE_BRAND} xl:[[data-theme=dark]_&]:border-r xl:[[data-theme=dark]_&]:[border-image:linear-gradient(to_bottom,rgba(125,164,255,0.55),#383838_75%)_1]` : "" }`} > @@ -352,11 +361,11 @@ function PlanGrid({ the bottom: it masks the middle of the bloom so the wash reads as a halo around the panel rather than a tint across the card. */}

{plan.name}

@@ -374,22 +383,47 @@ function PlanGrid({
- {/* Detached CTA — our standard rounded-lg button. Demo plans route - to the demo page; the rest to signup. */} - - {plan.cta} - + {/* Detached CTA — our standard rounded-lg button. One grid cell + (the table's second row), so a card without the secondary + button below it still lines up with the ones that have it. */} +
+ + {plan.cta} + + {/* Secondary demo CTA — a borderless ghost button: same size as + the primary above it, no outline, tinted on hover. */} + {plan.secondaryDemo && ( + + {DEMO_CTA_LABEL} + + )} +
-
+
{plan.featureGroups.map((group) => (
-

{group.label}

+

+ {group.extendsPrevious && prevName + ? `Everything in ${prevName}, plus` + : group.label} +

    {group.items.map((item) => (
  • + reflows to four even columns rather than leaving a gap. */} +
    {/* Both variants ship and `data-authed` on hides one before first paint (see globals.css). Choosing in an effect meant a - signed-in visitor watched four cards render and then reflow to - three. */} + signed-in visitor watched five cards render and then reflow to + four. */}
    - {/* Enterprise — a standalone outlined card below the plans table, detached - by a gap rather than sharing the table's border. */} -
); }