Skip to content

Commit 47f5a3b

Browse files
authored
docs: pay-first checkout — remove sign-up wall on pricing (#2870)
Logged-out buyers now go straight to Polar checkout instead of being sent to /signup first.
1 parent ade62f2 commit 47f5a3b

2 files changed

Lines changed: 123 additions & 17 deletions

File tree

docs/app/pricing/tiers.tsx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { cn } from "@/lib/fumadocs/cn";
44
import * as Sentry from "@sentry/nextjs";
55
import { track } from "@vercel/analytics";
66
import { CheckIcon } from "lucide-react";
7-
import React from "react";
7+
import React, { useState } from "react";
88

99
type Frequency = "month" | "year";
1010

@@ -37,11 +37,12 @@ function TierCTAButton({
3737
frequency: Frequency;
3838
}) {
3939
const { data: session } = useSession();
40+
const [isLoading, setIsLoading] = useState(false);
4041
let text =
4142
tier.cta === "get-started"
4243
? "Get Started"
4344
: tier.cta === "buy"
44-
? "Sign up"
45+
? "Buy now"
4546
: tier.cta === "contact"
4647
? "Contact us"
4748
: "Sign up";
@@ -71,6 +72,7 @@ function TierCTAButton({
7172
!isPurple &&
7273
!isGreen &&
7374
"bg-white border border-stone-300 text-stone-900 hover:border-purple-300 hover:text-purple-600",
75+
isLoading && "pointer-events-none opacity-70",
7476
);
7577

7678
return (
@@ -80,18 +82,19 @@ function TierCTAButton({
8082
return;
8183
}
8284

83-
track("Signup", { tier: tier.id });
84-
if (!session) {
85-
Sentry.captureEvent({
86-
message: "click-pricing-signup",
87-
level: "info",
88-
extra: { tier: tier.id },
89-
});
90-
track("click-pricing-signup", { tier: tier.id });
85+
// Prevent repeat clicks from opening duplicate checkout sessions while
86+
// the request is in flight.
87+
if (isLoading) {
88+
e.preventDefault();
89+
e.stopPropagation();
9190
return;
9291
}
9392

94-
if (session.planType === "free") {
93+
track("Signup", { tier: tier.id });
94+
if (!session || session.planType === "free") {
95+
// Pay-first: logged-out buyers go straight to checkout (no sign-up
96+
// wall). They're reconciled to an account by email in the webhook and
97+
// emailed a sign-in link (see lib/auth.ts).
9598
Sentry.captureEvent({
9699
message: "click-pricing-buy-now",
97100
level: "info",
@@ -104,7 +107,16 @@ function TierCTAButton({
104107
frequency === "year" && tier.id === "business"
105108
? "business-yearly"
106109
: tier.id;
107-
await authClient.checkout({ slug: checkoutSlug });
110+
setIsLoading(true);
111+
try {
112+
const ret = await authClient.checkout({ slug: checkoutSlug });
113+
if (ret?.error) {
114+
throw new Error(JSON.stringify(ret.error));
115+
}
116+
} catch (err) {
117+
Sentry.captureException(err);
118+
setIsLoading(false);
119+
}
108120
} else {
109121
const isCurrentPlan =
110122
tier.id === "business"
@@ -127,11 +139,18 @@ function TierCTAButton({
127139
}
128140
e.preventDefault();
129141
e.stopPropagation();
130-
await authClient.customer.portal();
142+
setIsLoading(true);
143+
try {
144+
await authClient.customer.portal();
145+
} catch (err) {
146+
Sentry.captureException(err);
147+
setIsLoading(false);
148+
}
131149
}
132150
}}
133-
href={tier.href ?? (session ? undefined : "/signup")}
151+
href={tier.href ?? undefined}
134152
aria-describedby={tier.id}
153+
aria-disabled={isLoading}
135154
className={buttonClasses}
136155
>
137156
{text}

docs/lib/auth.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ export const auth = betterAuth({
217217
},
218218
],
219219
successUrl: "/thanks",
220-
authenticatedUsersOnly: true,
220+
// Pay-first: allow logged-out checkout. The buyer is reconciled to a
221+
// BlockNote account by email in the webhook below
222+
// (resolveUserForCustomer), then emailed a sign-in link.
223+
authenticatedUsersOnly: false,
221224
}),
222225
portal(),
223226
webhooks({
@@ -230,11 +233,16 @@ export const auth = betterAuth({
230233
case "subscription.revoked":
231234
case "subscription.created":
232235
case "subscription.uncanceled": {
233-
const authContext = await auth.$context;
234-
const userId = payload.data.customer.externalId;
236+
// Resolve the BlockNote account for this purchase. For pay-first
237+
// (logged-out) checkouts the customer has no externalId, so this
238+
// creates/links an account by email and sends a sign-in link.
239+
const userId = await resolveUserForCustomer(
240+
payload.data.customer,
241+
);
235242
if (!userId) {
236243
return;
237244
}
245+
const authContext = await auth.$context;
238246
if (payload.data.status === "active") {
239247
const productId = payload.data.product.id;
240248
const planType = Object.values(PRODUCTS).find(
@@ -296,3 +304,82 @@ export const auth = betterAuth({
296304
}),
297305
},
298306
});
307+
308+
// For "pay-first" checkouts the buyer may not have an account yet: the Polar
309+
// customer has no externalId because they checked out while logged out. Resolve
310+
// the BlockNote user for a Polar customer — creating one keyed on the checkout
311+
// email when needed — so the purchase can be provisioned and the buyer gets
312+
// access (via a sign-in link) to the account holding their new plan.
313+
async function resolveUserForCustomer(customer: {
314+
id: string;
315+
externalId?: string | null;
316+
email?: string | null;
317+
name?: string | null;
318+
}): Promise<string | null> {
319+
// Authenticated purchase: the customer is already linked to a user.
320+
if (customer.externalId) {
321+
return customer.externalId;
322+
}
323+
const email = customer.email;
324+
if (!email) {
325+
return null;
326+
}
327+
328+
const authContext = await auth.$context;
329+
const existing = await authContext.internalAdapter.findUserByEmail(email);
330+
if (existing?.user) {
331+
// Existing account, but this logged-out purchase created an unlinked Polar
332+
// customer — link it so the buyer's subscription is found.
333+
await linkPolarCustomer(customer.id, existing.user.id);
334+
return existing.user.id;
335+
}
336+
337+
try {
338+
const created = await authContext.internalAdapter.createUser({
339+
email,
340+
name: customer.name || email,
341+
emailVerified: false,
342+
});
343+
// Link the Polar customer to the new account. Subscription and
344+
// customer-portal lookups resolve a user's customer by externalId, so
345+
// without this the buyer couldn't access or manage the plan they bought.
346+
await linkPolarCustomer(customer.id, created.id);
347+
// New account → email a sign-in link so they can access the plan they
348+
// just bought. A mail failure must not block provisioning.
349+
try {
350+
await auth.api.signInMagicLink({
351+
body: { email, callbackURL: "/pricing" },
352+
headers: new Headers(),
353+
});
354+
} catch (err) {
355+
Sentry.captureException(err);
356+
}
357+
return created.id;
358+
} catch (err) {
359+
// A concurrent webhook for the same purchase may have created the user
360+
// first (email is unique). Re-fetch instead of failing provisioning.
361+
const retry = await authContext.internalAdapter.findUserByEmail(email);
362+
if (retry?.user) {
363+
await linkPolarCustomer(customer.id, retry.user.id);
364+
return retry.user.id;
365+
}
366+
throw err;
367+
}
368+
}
369+
370+
// Link a Polar customer to a BlockNote user by setting the customer's
371+
// externalId. Subscription and customer-portal lookups resolve a user's Polar
372+
// customer by `externalId === user.id`, so a logged-out (pay-first) purchase
373+
// must be linked here or the buyer can't access/manage their subscription.
374+
// Best-effort: a failure is reported but must not block provisioning the plan,
375+
// and subsequent subscription events retry the link via the same path.
376+
async function linkPolarCustomer(customerId: string, userId: string) {
377+
try {
378+
await polarClient.customers.update({
379+
id: customerId,
380+
customerUpdate: { externalId: userId },
381+
});
382+
} catch (err) {
383+
Sentry.captureException(err);
384+
}
385+
}

0 commit comments

Comments
 (0)