Skip to content
Merged
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
59 changes: 59 additions & 0 deletions docs/app/pricing/PricingTiers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { cn } from "@/lib/fumadocs/cn";
import { useState } from "react";
import { Tier, Tiers } from "./tiers";

type Frequency = "month" | "year";

export function PricingTiers({ tiers }: { tiers: Tier[] }) {
const [frequency, setFrequency] = useState<Frequency>("year");

return (
<>
{/* Frequency Toggle */}
<div className="mb-10 flex items-center justify-center gap-3">
<span
className={cn(
"text-sm font-medium transition-colors",
frequency === "month" ? "text-stone-900" : "text-stone-400",
)}
>
Monthly
</span>
<button
type="button"
role="switch"
aria-checked={frequency === "year"}
onClick={() =>
setFrequency((f) => (f === "month" ? "year" : "month"))
}
className={cn(
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
frequency === "year" ? "bg-purple-600" : "bg-stone-300",
)}
Comment on lines +24 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Give the billing switch a programmatic label.

The control has role="switch" and aria-checked, but it still has no accessible name, so screen readers will announce an unnamed switch. Add aria-label or wire aria-labelledby to the visible “Monthly/Yearly” text.

Accessibility fix
         <button
           type="button"
           role="switch"
+          aria-label="Toggle billing frequency"
           aria-checked={frequency === "year"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
role="switch"
aria-checked={frequency === "year"}
onClick={() =>
setFrequency((f) => (f === "month" ? "year" : "month"))
}
className={cn(
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
frequency === "year" ? "bg-purple-600" : "bg-stone-300",
)}
<button
type="button"
role="switch"
aria-label="Toggle billing frequency"
aria-checked={frequency === "year"}
onClick={() =>
setFrequency((f) => (f === "month" ? "year" : "month"))
}
className={cn(
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
frequency === "year" ? "bg-purple-600" : "bg-stone-300",
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/pricing/PricingTiers.tsx` around lines 24 - 34, The billing toggle
button with role="switch" and aria-checked (in the component using frequency and
setFrequency) lacks an accessible name; update the button to include an
accessible label by either adding aria-label="Billing frequency toggle" or by
giving the visible “Monthly/Yearly” text an id and wiring
aria-labelledby="<that-id>" on the button so screen readers announce the switch
state; keep the existing frequency and setFrequency handlers intact and ensure
the chosen id is unique in the component.

>
<span
className={cn(
"pointer-events-none inline-block h-6 w-6 rounded-full bg-white shadow-sm ring-0 transition-transform",
frequency === "year" ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
<span
className={cn(
"text-sm font-medium transition-colors",
frequency === "year" ? "text-stone-900" : "text-stone-400",
)}
>
Yearly
</span>
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
Save 50%
</span>
</div>

<Tiers tiers={tiers} frequency={frequency} />
</>
);
}
9 changes: 5 additions & 4 deletions docs/app/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FAQ } from "@/app/pricing/faq";
import { Tier, Tiers } from "@/app/pricing/tiers";
import { Tier } from "@/app/pricing/tiers";
import { InfiniteSlider } from "@/components/InfiniteSlider";
import {
Tooltip,
Expand All @@ -9,6 +9,7 @@ import {
} from "@/components/ui/tooltip";
import { getFullMetadata } from "@/lib/getFullMetadata";
import Link from "next/link";
import { PricingTiers } from "./PricingTiers";

export const metadata = getFullMetadata({
title: "Pricing",
Expand Down Expand Up @@ -80,7 +81,7 @@ const tiers: Tier[] = [
badge: "Recommended",
description:
"Commercial license for access to advanced features and technical support.",
price: { month: 390, year: 48 },
price: { month: 390, year: 2340 },
features: [
<span key="commercial" className="font-semibold text-stone-900">
Commercial license for XL packages:
Expand Down Expand Up @@ -162,8 +163,8 @@ export default function Pricing() {
</p>
</div>

{/* Pricing Tiers */}
<Tiers tiers={tiers} frequency="month" />
{/* Pricing Tiers with Toggle */}
<PricingTiers tiers={tiers} />

{/* Social proof */}
<div className="mt-24 w-full border-t border-stone-200 pt-16">
Expand Down
55 changes: 44 additions & 11 deletions docs/app/pricing/tiers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export type Tier = {
cta?: "get-started" | "buy" | "contact";
};

function TierCTAButton({ tier }: { tier: Tier }) {
const BUSINESS_PLAN_TYPES = new Set(["business", "business-yearly"]);

function isBusinessPlan(planType: string) {
return BUSINESS_PLAN_TYPES.has(planType);
}

function TierCTAButton({ tier, frequency }: { tier: Tier; frequency: Frequency }) {
const { data: session } = useSession();
let text =
tier.cta === "get-started"
Expand All @@ -38,10 +44,11 @@ function TierCTAButton({ tier }: { tier: Tier }) {
if (session.planType === "free") {
text = "Buy now";
} else {
text =
session.planType === tier.id
? "Manage subscription"
: "Update subscription";
const isCurrentPlan =
tier.id === "business"
? isBusinessPlan(session.planType ?? "")
: session.planType === tier.id;
text = isCurrentPlan ? "Manage subscription" : "Update subscription";
}
}

Expand All @@ -68,9 +75,6 @@ function TierCTAButton({ tier }: { tier: Tier }) {
}

track("Signup", { tier: tier.id });
// ... rest of analytic logic kept simple for brevity in replacement,
// in real implementation we keep the existing logic.
// Re-injecting existing analytics logic below to ensure no regression.
if (!session) {
Sentry.captureEvent({
message: "click-pricing-signup",
Expand All @@ -90,9 +94,16 @@ function TierCTAButton({ tier }: { tier: Tier }) {
track("click-pricing-buy-now", { tier: tier.id });
e.preventDefault();
e.stopPropagation();
await authClient.checkout({ slug: tier.id });
const checkoutSlug = frequency === "year" && tier.id === "business"
? "business-yearly"
: tier.id;
await authClient.checkout({ slug: checkoutSlug });
} else {
if (session.planType === tier.id) {
const isCurrentPlan =
tier.id === "business"
? isBusinessPlan(session.planType ?? "")
: session.planType === tier.id;
if (isCurrentPlan) {
Sentry.captureEvent({
message: "click-pricing-manage-subscription",
level: "info",
Expand Down Expand Up @@ -208,6 +219,28 @@ export function Tiers({
<span className="text-3xl font-bold text-stone-900">
{tier.price}
</span>
) : frequency === "year" ? (
<div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-stone-900">
${Math.round(tier.price.year / 12)}
</span>
<span className="text-sm font-medium text-stone-400">
/month
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-sm text-stone-400 line-through decoration-stone-400">
${tier.price.month}/mo
</span>
<span className="rounded-md bg-green-100 px-1.5 py-0.5 text-xs font-semibold text-green-700">
now -50%
</span>
</div>
<p className="mt-1 text-xs text-stone-400">
${tier.price.year.toLocaleString()} billed yearly
</p>
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-stone-900">
Expand All @@ -227,7 +260,7 @@ export function Tiers({

{/* CTA */}
<div className="mb-6">
<TierCTAButton tier={tier} />
<TierCTAButton tier={tier} frequency={frequency} />
</div>

{/* Features */}
Expand Down
4 changes: 4 additions & 0 deletions docs/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export const auth = betterAuth({
productId: PRODUCTS.business.id, // ID of Product from Polar Dashboard
slug: PRODUCTS.business.slug, // Custom slug for easy reference in Checkout URL, e.g. /checkout/pro
},
{
productId: PRODUCTS["business-yearly"].id,
slug: PRODUCTS["business-yearly"].slug,
},
{
productId: PRODUCTS.starter.id,
slug: PRODUCTS.starter.slug,
Expand Down
8 changes: 8 additions & 0 deletions docs/lib/product-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export const PRODUCTS = {
name: "Business",
slug: "business",
} as const,
"business-yearly": {
id:
process.env.NODE_ENV === "production"
? "ba3965dc-e1ca-494e-b36a-62e2e41615d4"
: "NOT-CREATED",
name: "Business Yearly",
slug: "business-yearly",
} as const,
Comment on lines +10 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Expected: a placeholder non-production id in product-list,
# the slug registered in auth checkout config,
# and the pricing UI defaulting to yearly.
rg -n -C2 '"business-yearly"|NOT-CREATED' docs/lib/product-list.ts docs/lib/auth.ts
rg -n -C2 'useState<Frequency>\("year"\)' docs/app/pricing/PricingTiers.tsx

Repository: TypeCellOS/BlockNote

Length of output: 1089


Add sandbox product ID for non-production yearly flow or gate yearly outside production.

The yearly plan defaults in the UI (docs/app/pricing/PricingTiers.tsx, line 10) and is registered in checkout (docs/lib/auth.ts, lines 211–212), but uses the placeholder "NOT-CREATED" for non-production environments (docs/lib/product-list.ts, line 14). This prevents end-to-end testing of the yearly checkout flow in sandbox. Either create the missing Polar sandbox product and populate its ID, or disable the yearly option outside production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/lib/product-list.ts` around lines 10 - 17, The "business-yearly" product
entry uses the placeholder ID "NOT-CREATED" in non-production, which breaks
sandbox yearly checkout flows; either replace that placeholder with the actual
sandbox Polar product ID for the yearly plan or gate/disable the yearly option
outside production where "business-yearly" is referenced (e.g., in
docs/app/pricing/PricingTiers.tsx default selection and in docs/lib/auth.ts
checkout registration). Update the "business-yearly" object to contain the
sandbox product ID when NODE_ENV !== "production", or add an environment check
in PricingTiers.tsx/auth.ts to hide or disable the yearly option when the ID is
missing.

starter: {
id:
process.env.NODE_ENV === "production"
Expand Down
Loading