Skip to content
Open
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
47 changes: 33 additions & 14 deletions docs/app/pricing/tiers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Expand Down Expand 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 (
Expand All @@ -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",
Expand All @@ -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"
Expand All @@ -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}
Expand Down
93 changes: 90 additions & 3 deletions docs/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand Down Expand Up @@ -296,3 +304,82 @@ 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: {
id: string;
externalId?: string | null;
email?: string | null;
name?: string | null;
}): Promise<string | null> {
// 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) {
// 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;
}

try {
const created = await authContext.internalAdapter.createUser({
email,
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 {
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) {
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);
}
}
5 changes: 1 addition & 4 deletions examples/04-theming/06-code-block/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@ import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
// This packages some of the most used languages in on-demand bundle
import { codeBlockOptions, createHighlighter } from "@blocknote/code-block";
import { codeBlockOptions } from "@blocknote/code-block";

export default function App() {
// Creates a new editor instance.
const editor = useCreateBlockNote({
// The Shiki highlighter is configured at the editor level, separately from
// the code block's own options (default language & language menu).
syntaxHighlighting: { createHighlighter },
schema: BlockNoteSchema.create().extend({
blockSpecs: {
codeBlock: createCodeBlockSpec(codeBlockOptions),
Expand Down
12 changes: 3 additions & 9 deletions examples/06-custom-schema/09-math-block/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import "@blocknote/core/fonts/inter.css";
import { BlockNoteSchema } from "@blocknote/core";
import { BlockNoteSchema, createCodeBlockSpec } from "@blocknote/core";
import {
filterSuggestionItems,
insertOrUpdateBlockForSlashMenu,
} from "@blocknote/core/extensions";
import { createHighlighter } from "@blocknote/code-block";
import { codeBlockOptions } from "@blocknote/code-block";
import { createReactMathBlockSpec } from "@blocknote/math-block";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
Expand All @@ -19,6 +19,7 @@ import { TbMathFunction } from "react-icons/tb";
// that we want our editor to use.
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
codeBlock: createCodeBlockSpec(codeBlockOptions),
// Creates an instance of the Math block and adds it to the schema.
math: createReactMathBlockSpec(),
},
Expand All @@ -39,13 +40,6 @@ const insertMath = (editor: typeof schema.BlockNoteEditor) => ({

export default function App() {
const editor = useCreateBlockNote({
// Configures the syntax highlighting extension to always use LaTeX syntax highlighting in the
// Math block.
syntaxHighlighting: {
createHighlighter,
highlightBlock: (block) =>
block.type === "math" ? "latex" : block.props.language,
},
schema,
initialContent: [
{
Expand Down
5 changes: 1 addition & 4 deletions packages/code-block/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vite-plus/test";
import { codeBlockOptions, createHighlighter } from "./index.js";
import { codeBlockOptions } from "./index.js";

describe("codeBlock", () => {
it("should exist", () => {
Expand All @@ -11,7 +11,4 @@ describe("codeBlock", () => {
it("should have supportedLanguages", () => {
expect(codeBlockOptions.supportedLanguages).toBeDefined();
});
it("exports a separate createHighlighter", () => {
expect(createHighlighter).toBeDefined();
});
});
14 changes: 6 additions & 8 deletions packages/code-block/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import type { CodeBlockOptions } from "@blocknote/core";
import { createHighlighter as createShikiHighlighter } from "./shiki.bundle.js";
import { createHighlighter } from "./shiki.bundle.js";

export const createHighlighter = () =>
createShikiHighlighter({
themes: ["github-dark", "github-light"],
langs: [],
});

// TODO: Should this be here or in the core code block?
export const codeBlockOptions = {
defaultLanguage: "javascript",
supportedLanguages: {
Expand Down Expand Up @@ -204,4 +197,9 @@ export const codeBlockOptions = {
aliases: ["objective-c", "objc"],
},
},
createHighlighter: () =>
createHighlighter({
themes: ["github-dark", "github-light"],
langs: [],
}),
} satisfies CodeBlockOptions;
4 changes: 2 additions & 2 deletions packages/core/src/blocks/Code/CodeBlockOptions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// import type { ViewMutationRecord } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { BlockFromConfig } from "../../schema/index.js";
import { SyntaxHighlightingOptions } from "../../extensions/index.js";

/**
* Renders a preview of a code block's content (e.g. rendered LaTeX). Takes the
Expand Down Expand Up @@ -66,7 +66,7 @@ export type CodeBlockOptions = {
createPreview?: CodeBlockPreview;
}
>;
};
} & Partial<SyntaxHighlightingOptions>;

export function getLanguageId(
options: CodeBlockOptions,
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/blocks/Code/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CodeKeyboardShortcutsExtension } from "./helpers/extensions/CodeKeyboar
import { SourceBlockWithPreviewExtension } from "./helpers/extensions/SourceBlockWithPreviewExtension.js";
import { CodeBlockOptions } from "./CodeBlockOptions.js";
import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js";
import { SyntaxHighlightingExtension } from "../../extensions/index.js";

const CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY = "code-block-keyboard-shortcuts";
const CODE_BLOCK_PREVIEW_KEY = "code-block-preview";
Expand All @@ -34,6 +35,7 @@ export const createCodeBlockSpec = createBlockSpec(
code: true,
defining: true,
isolating: false,
highlight: (block) => block.props.language,
},
parse: (el) => parsePreCode(el),
parseContent: (opts) => parsePreCodeContent(opts, "codeBlock"),
Expand Down Expand Up @@ -61,6 +63,10 @@ export const createCodeBlockSpec = createBlockSpec(
!!options.supportedLanguages?.[block.props.language]?.createPreview,
runsBefore: [CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY],
}),
];
options.createHighlighter &&
SyntaxHighlightingExtension({
createHighlighter: options.createHighlighter,
}),
].filter((a) => !!a);
},
);
Loading
Loading