From 0f697dc8e25ed5c7077c44747e6be50e25fa1f9c Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 25 Jun 2026 08:23:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(docs):=20pay-first=20checkout=20?= =?UTF-8?q?=E2=80=94=20remove=20sign-up=20wall=20on=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logged-out buyers now go straight to Polar checkout instead of being sent to /signup first. The purchase is reconciled to a BlockNote account by email in the Polar webhook (creating the account if needed), and the buyer is emailed a magic sign-in link to access their plan. Also adds a loading/disabled state to the pricing CTAs so repeat clicks can't open duplicate Polar checkout sessions while a request is in flight. Motivated by funnel analysis: of ~9.2k quarterly /pricing visitors only ~0.36% clicked a buy CTA, two-thirds of buy intent bounced off the sign-up wall, and an unresponsive button produced duplicate checkout sessions (13 visitors -> 130 buy-now fires). - lib/auth.ts: authenticatedUsersOnly:false; resolveUserForCustomer() reconciles by externalId or email; signInMagicLink for access. - app/pricing/tiers.tsx: logged-out -> checkout; loading guard + try/catch. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/app/pricing/tiers.tsx | 47 ++++++++++++++++++-------- docs/lib/auth.ts | 67 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/docs/app/pricing/tiers.tsx b/docs/app/pricing/tiers.tsx index 2014f57b62..af1145c554 100644 --- a/docs/app/pricing/tiers.tsx +++ b/docs/app/pricing/tiers.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/fumadocs/cn"; import * as Sentry from "@sentry/nextjs"; import { track } from "@vercel/analytics"; import { CheckIcon } from "lucide-react"; -import React from "react"; +import React, { useState } from "react"; type Frequency = "month" | "year"; @@ -37,11 +37,12 @@ function TierCTAButton({ frequency: Frequency; }) { const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(false); let text = tier.cta === "get-started" ? "Get Started" : tier.cta === "buy" - ? "Sign up" + ? "Buy now" : tier.cta === "contact" ? "Contact us" : "Sign up"; @@ -71,6 +72,7 @@ function TierCTAButton({ !isPurple && !isGreen && "bg-white border border-stone-300 text-stone-900 hover:border-purple-300 hover:text-purple-600", + isLoading && "pointer-events-none opacity-70", ); return ( @@ -80,18 +82,19 @@ function TierCTAButton({ return; } - track("Signup", { tier: tier.id }); - if (!session) { - Sentry.captureEvent({ - message: "click-pricing-signup", - level: "info", - extra: { tier: tier.id }, - }); - track("click-pricing-signup", { tier: tier.id }); + // Prevent repeat clicks from opening duplicate checkout sessions while + // the request is in flight. + if (isLoading) { + e.preventDefault(); + e.stopPropagation(); return; } - if (session.planType === "free") { + track("Signup", { tier: tier.id }); + if (!session || session.planType === "free") { + // Pay-first: logged-out buyers go straight to checkout (no sign-up + // wall). They're reconciled to an account by email in the webhook and + // emailed a sign-in link (see lib/auth.ts). Sentry.captureEvent({ message: "click-pricing-buy-now", level: "info", @@ -104,7 +107,16 @@ function TierCTAButton({ frequency === "year" && tier.id === "business" ? "business-yearly" : tier.id; - await authClient.checkout({ slug: checkoutSlug }); + setIsLoading(true); + try { + const ret = await authClient.checkout({ slug: checkoutSlug }); + if (ret?.error) { + throw new Error(JSON.stringify(ret.error)); + } + } catch (err) { + Sentry.captureException(err); + setIsLoading(false); + } } else { const isCurrentPlan = tier.id === "business" @@ -127,11 +139,18 @@ function TierCTAButton({ } e.preventDefault(); e.stopPropagation(); - await authClient.customer.portal(); + setIsLoading(true); + try { + await authClient.customer.portal(); + } catch (err) { + Sentry.captureException(err); + setIsLoading(false); + } } }} - href={tier.href ?? (session ? undefined : "/signup")} + href={tier.href ?? undefined} aria-describedby={tier.id} + aria-disabled={isLoading} className={buttonClasses} > {text} diff --git a/docs/lib/auth.ts b/docs/lib/auth.ts index 654ab8e9e9..a49e623e9c 100644 --- a/docs/lib/auth.ts +++ b/docs/lib/auth.ts @@ -217,7 +217,10 @@ export const auth = betterAuth({ }, ], successUrl: "/thanks", - authenticatedUsersOnly: true, + // Pay-first: allow logged-out checkout. The buyer is reconciled to a + // BlockNote account by email in the webhook below + // (resolveUserForCustomer), then emailed a sign-in link. + authenticatedUsersOnly: false, }), portal(), webhooks({ @@ -230,11 +233,16 @@ export const auth = betterAuth({ case "subscription.revoked": case "subscription.created": case "subscription.uncanceled": { - const authContext = await auth.$context; - const userId = payload.data.customer.externalId; + // Resolve the BlockNote account for this purchase. For pay-first + // (logged-out) checkouts the customer has no externalId, so this + // creates/links an account by email and sends a sign-in link. + const userId = await resolveUserForCustomer( + payload.data.customer, + ); if (!userId) { return; } + const authContext = await auth.$context; if (payload.data.status === "active") { const productId = payload.data.product.id; const planType = Object.values(PRODUCTS).find( @@ -296,3 +304,56 @@ export const auth = betterAuth({ }), }, }); + +// For "pay-first" checkouts the buyer may not have an account yet: the Polar +// customer has no externalId because they checked out while logged out. Resolve +// the BlockNote user for a Polar customer — creating one keyed on the checkout +// email when needed — so the purchase can be provisioned and the buyer gets +// access (via a sign-in link) to the account holding their new plan. +async function resolveUserForCustomer(customer: { + externalId?: string | null; + email?: string | null; + name?: string | null; +}): Promise { + // Authenticated purchase: the customer is already linked to a user. + if (customer.externalId) { + return customer.externalId; + } + const email = customer.email; + if (!email) { + return null; + } + + const authContext = await auth.$context; + const existing = await authContext.internalAdapter.findUserByEmail(email); + if (existing?.user) { + return existing.user.id; + } + + try { + const created = await authContext.internalAdapter.createUser({ + email, + name: customer.name || email, + emailVerified: false, + }); + // New account → email a sign-in link so they can access the plan they + // just bought. A mail failure must not block provisioning. + try { + await auth.api.signInMagicLink({ + body: { email, callbackURL: "/pricing" }, + headers: new Headers(), + }); + } catch (err) { + Sentry.captureException(err); + } + return created.id; + } catch (err) { + // A concurrent webhook for the same purchase may have created the user + // first (email is unique). Re-fetch instead of failing provisioning. + const retry = await authContext.internalAdapter.findUserByEmail(email); + if (retry?.user) { + return retry.user.id; + } + throw err; + } +} From 740d61cbb858e42d81e3cdc297f31650aff06fb7 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 25 Jun 2026 08:40:25 +0200 Subject: [PATCH 2/2] fix(docs): explicitly link Polar customer to user on pay-first checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A logged-out checkout creates a Polar customer with no externalId. The webhook created the BlockNote account but relied on the Polar plugin's implicit user.create hook to back-fill externalId — which only runs when an auth context is present during the webhook, so the link wasn't guaranteed. Without it, customer.portal() and subscription lookups (which resolve by externalId === user.id) can't find the buyer's subscription. Add linkPolarCustomer() and call it explicitly after resolving/creating the user (created, existing, and race paths). Best-effort so a Polar API hiccup doesn't block provisioning the plan; subsequent subscription events retry the link via the same path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/lib/auth.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/lib/auth.ts b/docs/lib/auth.ts index a49e623e9c..f23ac97c28 100644 --- a/docs/lib/auth.ts +++ b/docs/lib/auth.ts @@ -311,6 +311,7 @@ export const auth = betterAuth({ // email when needed — so the purchase can be provisioned and the buyer gets // access (via a sign-in link) to the account holding their new plan. async function resolveUserForCustomer(customer: { + id: string; externalId?: string | null; email?: string | null; name?: string | null; @@ -327,6 +328,9 @@ async function resolveUserForCustomer(customer: { const authContext = await auth.$context; const existing = await authContext.internalAdapter.findUserByEmail(email); if (existing?.user) { + // Existing account, but this logged-out purchase created an unlinked Polar + // customer — link it so the buyer's subscription is found. + await linkPolarCustomer(customer.id, existing.user.id); return existing.user.id; } @@ -336,6 +340,10 @@ async function resolveUserForCustomer(customer: { name: customer.name || email, emailVerified: false, }); + // Link the Polar customer to the new account. Subscription and + // customer-portal lookups resolve a user's customer by externalId, so + // without this the buyer couldn't access or manage the plan they bought. + await linkPolarCustomer(customer.id, created.id); // New account → email a sign-in link so they can access the plan they // just bought. A mail failure must not block provisioning. try { @@ -352,8 +360,26 @@ async function resolveUserForCustomer(customer: { // first (email is unique). Re-fetch instead of failing provisioning. const retry = await authContext.internalAdapter.findUserByEmail(email); if (retry?.user) { + await linkPolarCustomer(customer.id, retry.user.id); return retry.user.id; } throw err; } } + +// Link a Polar customer to a BlockNote user by setting the customer's +// externalId. Subscription and customer-portal lookups resolve a user's Polar +// customer by `externalId === user.id`, so a logged-out (pay-first) purchase +// must be linked here or the buyer can't access/manage their subscription. +// Best-effort: a failure is reported but must not block provisioning the plan, +// and subsequent subscription events retry the link via the same path. +async function linkPolarCustomer(customerId: string, userId: string) { + try { + await polarClient.customers.update({ + id: customerId, + customerUpdate: { externalId: userId }, + }); + } catch (err) { + Sentry.captureException(err); + } +}