diff --git a/.agents/skills/paykit-architecture/SKILL.md b/.agents/skills/paykit-architecture/SKILL.md index 5903d067..5e844268 100644 --- a/.agents/skills/paykit-architecture/SKILL.md +++ b/.agents/skills/paykit-architecture/SKILL.md @@ -1,6 +1,6 @@ --- name: paykit-architecture -description: Use before architectural, API design, provider integration, billing lifecycle, database model, or product-scope decisions in PayKit. +description: not use automatically --- # PayKit Architecture diff --git a/AGENTS.md b/AGENTS.md index a5cfc121..e5ed666e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,25 @@ APIs. - Keep JSDoc strings short and useful - while writing JSDoc follow it's standards, such as tags +## References + +If you need to inspect source code of libraries or packages, prefer inspecting +cloned source repositories when available, rather than library dist files. + +These directories already contain source checkouts for some packages: + +- `~/ref/fumadocs` - fumadocs framework / package. + ## Behavior - When asked opinion questions, answer only. Do not edit code unless explicitly asked. - Never commit, push, or run database migrations unless explicitly asked. +- Never create a partial commit from files that also contain unstaged changes. + If a requested commit overlaps dirty files, either commit the full intended + change or stop and ask. Do not rely on hooks stashing/hiding unstaged changes. +- If a commit hook fails while hiding or restoring unstaged changes, stop + immediately and recover the user's unstaged work before retrying or committing + anything. - When generating migrations, always provide a name. - Never edit past migrations; create a new migration instead. - Never run "deploy" scripts to test anything, only if explicitly asked diff --git a/README.md b/README.md index bf184ef4..a29e7224 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The billing framework for TypeScript

- Define products in code. Any provider. Gate features. Track usage. + Define plans in code. Gate features. Track usage. Webhooks handled for you.

@@ -37,7 +37,6 @@ PayKit is an embedded billing framework for TypeScript apps. It sits inside your app, uses your database, and gives you a single API to manage products, subscriptions, entitlements, and usage billing without touching provider dashboards. ```ts -import { stripe } from "@paykitjs/stripe"; import { createPayKit, feature, plan } from "paykitjs"; const messages = feature({ id: "messages", type: "metered" }); @@ -57,10 +56,10 @@ const pro = plan({ }); export const paykit = createPayKit({ - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), + }, database: process.env.DATABASE_URL!, products: [free, pro], }); diff --git a/apps/demo/drizzle.config.ts b/apps/demo/drizzle.config.ts index fb6c47b1..7d52f3c5 100644 --- a/apps/demo/drizzle.config.ts +++ b/apps/demo/drizzle.config.ts @@ -4,8 +4,9 @@ import "dotenv/config"; export default defineConfig({ dialect: "postgresql", schema: "../../packages/paykit/src/database/schema.ts", + out: "../../packages/paykit/src/database/migrations", dbCredentials: { - url: process.env.POLAR_DATABASE_URL!, + url: process.env.PAYKIT_DATABASE_URL!, }, migrations: { schema: "public", diff --git a/apps/demo/next.config.js b/apps/demo/next.config.js index 4669aed0..f6c58677 100644 --- a/apps/demo/next.config.js +++ b/apps/demo/next.config.js @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; /** @type {import("next").NextConfig} */ const config = { - transpilePackages: ["paykitjs", "@paykitjs/polar", "@paykitjs/stripe", "autumn-js"], + transpilePackages: ["paykitjs", "autumn-js"], serverExternalPackages: ["pg"], turbopack: { root: fileURLToPath(new URL("../..", import.meta.url)), diff --git a/apps/demo/package.json b/apps/demo/package.json index 1b02511c..490de3ab 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -18,17 +18,14 @@ "tunnel": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$CF_TUNNEL_ID\" ]; then cloudflared tunnel --url http://localhost:3000 run \"$CF_TUNNEL_ID\"; fi", "paykitjs": "bun ../../packages/paykit/src/cli/index.ts", "typecheck": "tsc --noEmit", - "push": "bun push:auth && bun push:paykit:polar && bun push:paykit:stripe && bun push:autumn", + "push": "bun push:auth && bun push:paykit && bun push:autumn", "push:auth": "bunx auth migrate --config src/lib/auth.ts --yes", - "push:paykit:polar": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$POLAR_DATABASE_URL\" ] && [ -n \"$POLAR_ACCESS_TOKEN\" ] && [ -n \"$POLAR_WEBHOOK_SECRET\" ]; then bunx paykitjs push --config paykit.polar.config.ts --yes; else printf '%s\\n' 'Skipping PayKit Polar push: provider env incomplete'; fi", - "push:paykit:stripe": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$STRIPE_DATABASE_URL\" ] && [ -n \"$STRIPE_SECRET_KEY\" ] && [ -n \"$STRIPE_WEBHOOK_SECRET\" ]; then bunx paykitjs push --config paykit.stripe.config.ts --yes; else printf '%s\\n' 'Skipping PayKit Stripe push: provider env incomplete'; fi", + "push:paykit": "set -a; [ -f .env ] && . ./.env; set +a; bunx paykitjs push --config paykit.config.ts --yes", "push:autumn": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$AUTUMN_SECRET_KEY\" ]; then atmn push; else printf '%s\\n' 'Skipping Autumn push: provider env incomplete'; fi", "db:studio": "drizzle-kit studio" }, "dependencies": { "@base-ui/react": "^1.2.0", - "@paykitjs/polar": "workspace:*", - "@paykitjs/stripe": "workspace:*", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", "@trpc/client": "^11.0.0", diff --git a/apps/demo/paykit.config.ts b/apps/demo/paykit.config.ts new file mode 100644 index 00000000..4196ea45 --- /dev/null +++ b/apps/demo/paykit.config.ts @@ -0,0 +1,4 @@ +import { paykit } from "./src/lib/paykit"; + +export { paykit }; +export default paykit; diff --git a/apps/demo/paykit.polar.config.ts b/apps/demo/paykit.polar.config.ts deleted file mode 100644 index a3c394ef..00000000 --- a/apps/demo/paykit.polar.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { requirePaykitPolar } from "./src/lib/paykit/polar"; - -export const paykit = requirePaykitPolar(); -export default paykit; diff --git a/apps/demo/paykit.stripe.config.ts b/apps/demo/paykit.stripe.config.ts deleted file mode 100644 index 587ddd69..00000000 --- a/apps/demo/paykit.stripe.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { requirePaykitStripe } from "./src/lib/paykit/stripe"; - -export const paykit = requirePaykitStripe(); -export default paykit; diff --git a/apps/demo/scripts/push-sandbox.ts b/apps/demo/scripts/push-sandbox.ts index b84b56d8..cd7c06be 100644 --- a/apps/demo/scripts/push-sandbox.ts +++ b/apps/demo/scripts/push-sandbox.ts @@ -30,28 +30,16 @@ async function main() { env, ); - if (hasEnv(values, ["POLAR_DATABASE_URL", "POLAR_ACCESS_TOKEN", "POLAR_WEBHOOK_SECRET"])) { - console.log("Push PayKit Polar config"); + if (hasEnv(values, ["PAYKIT_DATABASE_URL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"])) { + console.log("Push PayKit config"); await runCommand( "bunx", - ["paykitjs", "push", "--config", "paykit.polar.config.ts", "--yes"], + ["paykitjs", "push", "--config", "paykit.config.ts", "--yes"], demoDir, env, ); } else { - console.log("Skipping PayKit Polar push: provider env incomplete"); - } - - if (hasEnv(values, ["STRIPE_DATABASE_URL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"])) { - console.log("Push PayKit Stripe config"); - await runCommand( - "bunx", - ["paykitjs", "push", "--config", "paykit.stripe.config.ts", "--yes"], - demoDir, - env, - ); - } else { - console.log("Skipping PayKit Stripe push: provider env incomplete"); + console.log("Skipping PayKit push: env incomplete"); } if (hasEnv(values, ["AUTUMN_SECRET_KEY"])) { diff --git a/apps/demo/scripts/sandbox.ts b/apps/demo/scripts/sandbox.ts index ffa1b54d..b1f88a76 100644 --- a/apps/demo/scripts/sandbox.ts +++ b/apps/demo/scripts/sandbox.ts @@ -15,7 +15,7 @@ export const sandboxConfig = { target: "production", } as const; -export const paykitPackages = ["paykitjs", "@paykitjs/polar", "@paykitjs/stripe"] as const; +export const paykitPackages = ["paykitjs"] as const; const scriptsDir = fileURLToPath(new URL(".", import.meta.url)); diff --git a/apps/demo/src/app/_components/checkout-page-content.tsx b/apps/demo/src/app/_components/checkout-page-content.tsx index 3c121f64..73275a99 100644 --- a/apps/demo/src/app/_components/checkout-page-content.tsx +++ b/apps/demo/src/app/_components/checkout-page-content.tsx @@ -13,18 +13,16 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { authClient } from "@/lib/auth-client"; -import { paykitScenarios } from "@/lib/paykit-scenarios"; -import type { PayKitScenario } from "@/lib/paykit-scenarios"; import { api } from "@/trpc/react"; -function PayKitTabContent({ scenario }: { scenario: PayKitScenario }) { +function PayKitTabContent() { return ( - - + +

Features

- +
); @@ -37,14 +35,8 @@ export function CheckoutPageContent() { const router = useRouter(); const toastShown = useRef(false); - const configuredPaykitScenarios = paykitScenarios.filter( - (scenario) => scenarios.data?.[scenario.id]?.configured, - ); const hasAutumn = scenarios.data?.autumn?.configured === true; - const availableTabs = [ - ...configuredPaykitScenarios.map((scenario) => scenario.tab), - ...(hasAutumn ? ["autumn-stripe"] : []), - ]; + const availableTabs = ["paykit", ...(hasAutumn ? ["autumn-stripe"] : [])]; const tab = searchParams.get("tab"); const activeTab = tab && availableTabs.includes(tab) ? tab : availableTabs[0]; const setTab = useCallback( @@ -109,18 +101,6 @@ export function CheckoutPageContent() { ); } - if (!activeTab) { - return ( -
-

Billing

-

- No billing providers are configured. Add a complete provider env group and restart the - demo. -

-
- ); - } - return (
@@ -146,16 +126,10 @@ export function CheckoutPageContent() { - {configuredPaykitScenarios.map((scenario) => ( - - {scenario.label} - - ))} + PayKit {hasAutumn ? Autumn Stripe : null} - {configuredPaykitScenarios.map((scenario) => ( - - ))} + {hasAutumn ? ( diff --git a/apps/demo/src/app/_components/features-panel.tsx b/apps/demo/src/app/_components/features-panel.tsx index 5efaa405..9a9799bc 100644 --- a/apps/demo/src/app/_components/features-panel.tsx +++ b/apps/demo/src/app/_components/features-panel.tsx @@ -7,11 +7,9 @@ import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { featureCatalog } from "@/lib/demo-catalog"; -import type { PayKitScenario } from "@/lib/paykit-scenarios"; import { api } from "@/trpc/react"; function MeteredFeatureRow({ - scenario, featureId, name, description, @@ -19,11 +17,10 @@ function MeteredFeatureRow({ description: string; featureId: string; name: string; - scenario: PayKitScenario; }) { const utils = api.useUtils(); - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; - const paykitUtils = scenario === "polar" ? utils.paykitPolar : utils.paykitStripe; + const paykitApi = api.paykit; + const paykitUtils = utils.paykit; const { data, isLoading } = paykitApi.checkFeature.useQuery({ featureId, }); @@ -78,7 +75,6 @@ function MeteredFeatureRow({ } function BooleanFeatureRow({ - scenario, featureId, name, description, @@ -86,9 +82,8 @@ function BooleanFeatureRow({ description: string; featureId: string; name: string; - scenario: PayKitScenario; }) { - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; + const paykitApi = api.paykit; const { data, isLoading } = paykitApi.checkFeature.useQuery({ featureId, }); @@ -110,14 +105,13 @@ function BooleanFeatureRow({ ); } -export function FeaturesPanel({ scenario }: { scenario: PayKitScenario }) { +export function FeaturesPanel() { return (
{featureCatalog.map((feat) => feat.type === "metered" ? ( - - - Test clock - Not supported - - - This provider does not support test clocks, so billing time cannot be advanced in the - demo. - - - - ); -} - -function TestClockLoadingPanel() { - return ( - - - Test clock - Checking provider support. - - - - - - - ); -} - -function TestClockErrorPanel({ message }: { message: string }) { - return ( - - - Test clock - Failed to determine test clock support. - - -

{message}

-
-
- ); -} - function StripeTestClockPanel() { const utils = api.useUtils(); const queryClient = useQueryClient(); const testClock = useQuery({ - queryFn: async () => paykitStripeClient.getTestClock({}), + queryFn: async () => paykitClient.getTestClock({}), queryKey: stripeTestClockQueryKey, }); const advanceClock = useMutation({ mutationFn: async (frozenTime: Date) => { - const result = await paykitStripeClient.advanceTestClock({ frozenTime }); + const result = await paykitClient.advanceTestClock({ frozenTime }); return result; }, onError: (error) => { @@ -151,8 +104,8 @@ function StripeTestClockPanel() { toast.success("Advanced test clock"); await Promise.all([ queryClient.invalidateQueries({ queryKey: stripeTestClockQueryKey }), - utils.paykitStripe.currentPlans.invalidate(), - utils.paykitStripe.checkFeature.invalidate(), + utils.paykit.currentPlans.invalidate(), + utils.paykit.checkFeature.invalidate(), ]); }, }); @@ -231,12 +184,10 @@ function StripeTestClockPanel() { ); } -export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { +export function SubscribePanel() { const utils = api.useUtils(); - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; - const paykitUtils = scenario === "polar" ? utils.paykitPolar : utils.paykitStripe; - const paykitClient = scenario === "polar" ? paykitPolarClient : paykitStripeClient; - const capabilities = paykitApi.capabilities.useQuery(); + const paykitApi = api.paykit; + const paykitUtils = utils.paykit; const { data: currentPlans, isLoading: isLoadingPlans } = paykitApi.currentPlans.useQuery(); const activePlan = currentPlans?.find((plan) => ["active", "trialing", "past_due"].includes(plan.status)) ?? null; @@ -258,8 +209,8 @@ export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { mutationFn: async ({ planId }: { planId: PlanId }) => { const result = await paykitClient.subscribe({ planId, - successUrl: `/?tab=paykit-${scenario}&checkout=success`, - cancelUrl: `/?tab=paykit-${scenario}&checkout=canceled`, + successUrl: "/?tab=paykit&checkout=success", + cancelUrl: "/?tab=paykit&checkout=canceled", }); return { planId, result }; }, @@ -332,21 +283,7 @@ export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { )} - {capabilities.isLoading ? ( - - ) : capabilities.isError ? ( - - ) : capabilities.data?.testClocks && scenario === "stripe" ? ( - - ) : ( - - )} + diff --git a/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts b/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts deleted file mode 100644 index 1f13e1b8..00000000 --- a/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getPaykitPolar } from "@/lib/paykit/polar"; - -function handle(request: Request) { - const paykit = getPaykitPolar(); - if (!paykit) { - return Response.json({ error: "PayKit Polar is not configured" }, { status: 404 }); - } - return paykit.handler(request); -} - -export const GET = handle; -export const POST = handle; diff --git a/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts b/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts deleted file mode 100644 index f19e5676..00000000 --- a/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getPaykitStripe } from "@/lib/paykit/stripe"; - -function handle(request: Request) { - const paykit = getPaykitStripe(); - if (!paykit) { - return Response.json({ error: "PayKit Stripe is not configured" }, { status: 404 }); - } - return paykit.handler(request); -} - -export const GET = handle; -export const POST = handle; diff --git a/apps/demo/src/app/paykit/[[...slug]]/route.ts b/apps/demo/src/app/paykit/[[...slug]]/route.ts new file mode 100644 index 00000000..216398fc --- /dev/null +++ b/apps/demo/src/app/paykit/[[...slug]]/route.ts @@ -0,0 +1,4 @@ +import { paykit } from "@/lib/paykit"; + +export const GET = paykit.handler; +export const POST = paykit.handler; diff --git a/apps/demo/src/env.js b/apps/demo/src/env.js index 54847974..a3988a46 100644 --- a/apps/demo/src/env.js +++ b/apps/demo/src/env.js @@ -10,12 +10,9 @@ export const env = createEnv({ APP_URL: z.url(), AUTH_DATABASE_URL: z.string().min(1), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - POLAR_DATABASE_URL: z.string().min(1).optional(), - POLAR_ACCESS_TOKEN: z.string().min(1).optional(), - POLAR_WEBHOOK_SECRET: z.string().min(1).optional(), - STRIPE_DATABASE_URL: z.string().min(1).optional(), - STRIPE_SECRET_KEY: z.string().min(1).optional(), - STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), + PAYKIT_DATABASE_URL: z.string().min(1), + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), BETTER_AUTH_SECRET: z.string().min(1), AUTUMN_SECRET_KEY: z.string().min(1).optional(), }, @@ -37,10 +34,7 @@ export const env = createEnv({ APP_URL: process.env.APP_URL, AUTH_DATABASE_URL: process.env.AUTH_DATABASE_URL, NODE_ENV: process.env.NODE_ENV, - POLAR_DATABASE_URL: process.env.POLAR_DATABASE_URL, - POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN, - POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET, - STRIPE_DATABASE_URL: process.env.STRIPE_DATABASE_URL, + PAYKIT_DATABASE_URL: process.env.PAYKIT_DATABASE_URL, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, diff --git a/apps/demo/src/lib/paykit-client.ts b/apps/demo/src/lib/paykit-client.ts index 885238f1..a057ebc9 100644 --- a/apps/demo/src/lib/paykit-client.ts +++ b/apps/demo/src/lib/paykit-client.ts @@ -1,14 +1,9 @@ import { createPayKitClient } from "paykitjs/client"; -import type { PaykitPolarInstance } from "@/lib/paykit/polar"; -import type { PaykitStripeInstance } from "@/lib/paykit/stripe"; +import type { PayKitInstance } from "@/lib/paykit"; -type ClientInstance = T & { options: { identify: (...args: never[]) => unknown } }; +type ClientInstance = T & { options: T extends { options: infer TOptions } ? TOptions : never }; -export const paykitPolarClient = createPayKitClient>({ - baseURL: "/paykit-polar", -}); - -export const paykitStripeClient = createPayKitClient>({ - baseURL: "/paykit-stripe", +export const paykitClient = createPayKitClient>({ + baseURL: "/paykit", }); diff --git a/apps/demo/src/lib/paykit-scenarios.ts b/apps/demo/src/lib/paykit-scenarios.ts deleted file mode 100644 index a02a7738..00000000 --- a/apps/demo/src/lib/paykit-scenarios.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type PayKitScenario = "polar" | "stripe"; - -export const paykitScenarios = [ - { id: "polar", label: "PayKit Polar", tab: "paykit-polar" }, - { id: "stripe", label: "PayKit Stripe", tab: "paykit-stripe" }, -] as const satisfies ReadonlyArray<{ id: PayKitScenario; label: string; tab: string }>; diff --git a/apps/demo/src/lib/paykit.ts b/apps/demo/src/lib/paykit.ts index 6262c908..51475c24 100644 --- a/apps/demo/src/lib/paykit.ts +++ b/apps/demo/src/lib/paykit.ts @@ -1,2 +1,29 @@ -export { requirePaykitPolar as paykit } from "@/lib/paykit/polar"; -export type { PayKitPolar as PayKit } from "@/lib/paykit/polar"; +import { createPayKit } from "paykitjs"; + +import { env } from "@/env"; +import { auth } from "@/lib/auth"; +import { free, pro, ultra } from "@/lib/paykit-products"; +import { paykitPool } from "@/server/db"; + +export const paykit = createPayKit({ + basePath: "/paykit", + database: paykitPool, + stripe: { + secretKey: env.STRIPE_SECRET_KEY, + webhookSecret: env.STRIPE_WEBHOOK_SECRET, + }, + testing: { enabled: true }, + products: [pro, ultra, free], + identify: async (request) => { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) return null; + return { + customerId: session.user.id, + email: session.user.email, + name: session.user.name ?? undefined, + }; + }, +}); + +export type PayKit = (typeof paykit)["$infer"]; +export type PayKitInstance = typeof paykit; diff --git a/apps/demo/src/lib/paykit/polar.ts b/apps/demo/src/lib/paykit/polar.ts deleted file mode 100644 index 1d679f06..00000000 --- a/apps/demo/src/lib/paykit/polar.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { polar } from "@paykitjs/polar"; -import { createPayKit } from "paykitjs"; - -import { env } from "@/env"; -import { auth } from "@/lib/auth"; -import { free, pro, ultra } from "@/lib/paykit-products"; -import { requireScenarioEnv, scenarioConfig } from "@/lib/scenario-config"; -import { getPaykitPolarPool } from "@/server/db"; - -function createPaykitPolar() { - return createPayKit({ - basePath: "/paykit-polar", - database: getPaykitPolarPool(), - provider: polar({ - accessToken: requireScenarioEnv(env.POLAR_ACCESS_TOKEN, "POLAR_ACCESS_TOKEN"), - webhookSecret: requireScenarioEnv(env.POLAR_WEBHOOK_SECRET, "POLAR_WEBHOOK_SECRET"), - server: "sandbox", - }), - products: [pro, ultra, free], - identify: async (request) => { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return null; - return { - customerId: session.user.id, - email: session.user.email, - name: session.user.name ?? undefined, - }; - }, - }); -} - -export type PaykitPolarInstance = ReturnType; - -let paykitPolar: PaykitPolarInstance | undefined; - -export function isPaykitPolarConfigured() { - return scenarioConfig.polar.configured; -} - -export function getPaykitPolar() { - if (!isPaykitPolarConfigured()) return null; - paykitPolar ??= createPaykitPolar(); - return paykitPolar; -} - -export function requirePaykitPolar() { - const paykit = getPaykitPolar(); - if (!paykit) throw new Error("PayKit Polar is not configured"); - return paykit; -} - -export type PayKitPolar = PaykitPolarInstance["$infer"]; diff --git a/apps/demo/src/lib/paykit/stripe.ts b/apps/demo/src/lib/paykit/stripe.ts deleted file mode 100644 index 359cb76f..00000000 --- a/apps/demo/src/lib/paykit/stripe.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { stripe } from "@paykitjs/stripe"; -import { createPayKit } from "paykitjs"; - -import { env } from "@/env"; -import { auth } from "@/lib/auth"; -import { free, pro, ultra } from "@/lib/paykit-products"; -import { requireScenarioEnv, scenarioConfig } from "@/lib/scenario-config"; -import { getPaykitStripePool } from "@/server/db"; - -function createPaykitStripe() { - return createPayKit({ - basePath: "/paykit-stripe", - database: getPaykitStripePool(), - provider: stripe({ - secretKey: requireScenarioEnv(env.STRIPE_SECRET_KEY, "STRIPE_SECRET_KEY"), - webhookSecret: requireScenarioEnv(env.STRIPE_WEBHOOK_SECRET, "STRIPE_WEBHOOK_SECRET"), - }), - testing: { enabled: true }, - products: [pro, ultra, free], - identify: async (request) => { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return null; - return { - customerId: session.user.id, - email: session.user.email, - name: session.user.name ?? undefined, - }; - }, - }); -} - -export type PaykitStripeInstance = ReturnType; - -let paykitStripe: PaykitStripeInstance | undefined; - -export function isPaykitStripeConfigured() { - return scenarioConfig.stripe.configured; -} - -export function getPaykitStripe() { - if (!isPaykitStripeConfigured()) return null; - paykitStripe ??= createPaykitStripe(); - return paykitStripe; -} - -export function requirePaykitStripe() { - const paykit = getPaykitStripe(); - if (!paykit) throw new Error("PayKit Stripe is not configured"); - return paykit; -} - -export type PayKitStripe = PaykitStripeInstance["$infer"]; diff --git a/apps/demo/src/lib/scenario-config.ts b/apps/demo/src/lib/scenario-config.ts index e69405f4..d14eca9e 100644 --- a/apps/demo/src/lib/scenario-config.ts +++ b/apps/demo/src/lib/scenario-config.ts @@ -6,20 +6,6 @@ export const scenarioConfig = { label: "Autumn Stripe", tab: "autumn-stripe", }, - polar: { - configured: Boolean( - env.POLAR_DATABASE_URL && env.POLAR_ACCESS_TOKEN && env.POLAR_WEBHOOK_SECRET, - ), - label: "PayKit Polar", - tab: "paykit-polar", - }, - stripe: { - configured: Boolean( - env.STRIPE_DATABASE_URL && env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SECRET, - ), - label: "PayKit Stripe", - tab: "paykit-stripe", - }, } as const; export type ScenarioConfig = typeof scenarioConfig; @@ -29,13 +15,3 @@ export function getConfiguredScenarios() { Object.entries(scenarioConfig).filter(([, scenario]) => scenario.configured), ) as Partial; } - -export function requireScenarioEnv( - value: T, - name: string, -): NonNullable { - if (!value) { - throw new Error(`Missing ${name}`); - } - return value; -} diff --git a/apps/demo/src/server/api/root.ts b/apps/demo/src/server/api/root.ts index 00fa59aa..d71150b9 100644 --- a/apps/demo/src/server/api/root.ts +++ b/apps/demo/src/server/api/root.ts @@ -1,5 +1,4 @@ -import { getPaykitPolar } from "@/lib/paykit/polar"; -import { getPaykitStripe } from "@/lib/paykit/stripe"; +import { paykit } from "@/lib/paykit"; import { getConfiguredScenarios } from "@/lib/scenario-config"; import { autumnRouter } from "@/server/api/routers/autumn"; import { createPaykitRouter } from "@/server/api/routers/paykit-route"; @@ -13,8 +12,7 @@ import { createCallerFactory, createTRPCRouter, publicProcedure } from "@/server */ export const appRouter = createTRPCRouter({ autumn: autumnRouter, - paykitPolar: createPaykitRouter(getPaykitPolar), - paykitStripe: createPaykitRouter(getPaykitStripe), + paykit: createPaykitRouter(() => paykit), post: postRouter, scenarios: createTRPCRouter({ list: publicProcedure.query(() => getConfiguredScenarios()), diff --git a/apps/demo/src/server/api/routers/paykit-route.ts b/apps/demo/src/server/api/routers/paykit-route.ts index 836de574..8885c927 100644 --- a/apps/demo/src/server/api/routers/paykit-route.ts +++ b/apps/demo/src/server/api/routers/paykit-route.ts @@ -7,7 +7,7 @@ import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; type DemoPayKit = { $infer: { featureId: TFeatureId }; - options: Pick; + options: Pick; check(input: { customerId: string; featureId: TFeatureId; @@ -29,17 +29,12 @@ export function createPaykitRouter( function requirePaykit() { const paykit = getPaykit(); if (!paykit) { - throw new TRPCError({ code: "NOT_FOUND", message: "PayKit provider is not configured" }); + throw new TRPCError({ code: "NOT_FOUND", message: "PayKit is not configured" }); } return paykit; } return createTRPCRouter({ - capabilities: publicProcedure.query(() => { - const paykit = getPaykit(); - return paykit?.options.provider.capabilities ?? { testClocks: false }; - }), - createCustomer: publicProcedure.mutation(async ({ ctx }) => { const paykit = requirePaykit(); const session = await auth.api.getSession({ headers: ctx.headers }); diff --git a/apps/demo/src/server/db.ts b/apps/demo/src/server/db.ts index ee29704d..df1f24ee 100644 --- a/apps/demo/src/server/db.ts +++ b/apps/demo/src/server/db.ts @@ -4,29 +4,16 @@ import { env } from "@/env"; const globalForPool = globalThis as typeof globalThis & { demoAuthPool?: Pool; - demoPaykitPolarPool?: Pool; - demoPaykitStripePool?: Pool; + demoPaykitPool?: Pool; }; export const authPool = globalForPool.demoAuthPool ?? new Pool({ connectionString: env.AUTH_DATABASE_URL }); -export function getPaykitPolarPool() { - if (!env.POLAR_DATABASE_URL) { - throw new Error("Missing POLAR_DATABASE_URL"); - } - globalForPool.demoPaykitPolarPool ??= new Pool({ connectionString: env.POLAR_DATABASE_URL }); - return globalForPool.demoPaykitPolarPool; -} - -export function getPaykitStripePool() { - if (!env.STRIPE_DATABASE_URL) { - throw new Error("Missing STRIPE_DATABASE_URL"); - } - globalForPool.demoPaykitStripePool ??= new Pool({ connectionString: env.STRIPE_DATABASE_URL }); - return globalForPool.demoPaykitStripePool; -} +export const paykitPool = + globalForPool.demoPaykitPool ?? new Pool({ connectionString: env.PAYKIT_DATABASE_URL }); if (process.env.NODE_ENV !== "production") { globalForPool.demoAuthPool = authPool; + globalForPool.demoPaykitPool = paykitPool; } diff --git a/apps/web/content/docs/concepts/cli.mdx b/apps/web/content/docs/cli.mdx similarity index 79% rename from apps/web/content/docs/concepts/cli.mdx rename to apps/web/content/docs/cli.mdx index d239f1d5..efe60c09 100644 --- a/apps/web/content/docs/concepts/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -5,11 +5,13 @@ description: Project initialization and plan management from the command line. PayKit includes a CLI tool for project setup, database migrations, and plan syncing. Install it once and use it throughout the project lifecycle. -## `paykitjs init` +## paykitjs init - +```bash +npx paykitjs init +``` -An interactive setup wizard that scaffolds everything you need to get started. It asks you to pick a provider (Stripe, Polar, or Creem), then generates: +An interactive setup wizard that scaffolds everything you need to get started. It configures Stripe, then generates: - A `paykit.ts` config file with an example plan structure - A route handler for webhooks @@ -17,14 +19,16 @@ An interactive setup wizard that scaffolds everything you need to get started. I Run this once when starting a new project. -## `paykitjs push` +## paykitjs push - +```bash +npx paykitjs push +``` The command you'll run most often. It does two things: 1. Applies any pending database migrations to keep your schema up to date -2. Syncs your plan definitions to the database and your payment provider, creating or updating products and prices in Stripe (or whichever provider you're using) +2. Syncs your plan definitions to the database and Stripe, creating or updating products and prices in Stripe Run it on initial setup and again whenever you change your plan configuration. @@ -42,9 +46,11 @@ paykitjs push -y && next build This is similar to how you'd run database migrations on deploy. The `-y` flag skips the confirmation prompt. -## `paykitjs status` +## paykitjs status - +```bash +npx paykitjs status +``` Validates your entire PayKit setup without making any changes. Useful when debugging a broken environment. It checks: @@ -56,7 +62,9 @@ Validates your entire PayKit setup without making any changes. Useful when debug Pass `--throw` to exit with code 1 on failures, useful for CI pipelines: - +```bash +npx paykitjs status --throw +``` ## Telemetry diff --git a/apps/web/content/docs/concepts/client.mdx b/apps/web/content/docs/client.mdx similarity index 88% rename from apps/web/content/docs/concepts/client.mdx rename to apps/web/content/docs/client.mdx index adf332b7..2ac28a3a 100644 --- a/apps/web/content/docs/concepts/client.mdx +++ b/apps/web/content/docs/client.mdx @@ -16,13 +16,13 @@ import type { paykit } from "@/server/paykit"; export const paykitClient = createPayKitClient(); ``` -The client resolves the current customer automatically on each request. You need `identify` configured on your server instance for this to work. See [customer identification](/docs/concepts/customers#customer-identification-client) for details. +The client resolves the current customer automatically on each request. You need `identify` configured on your server instance for this to work. See [customer identification](/docs/customers#customer-identification-client) for details. ## Available methods The client exposes `subscribe` and `customerPortal`. Neither requires a `customerId` since it's resolved from the incoming request via `identify`. -## `subscribe` +## subscribe Works the same as the server-side `subscribe`, but without `customerId`. Returns `{ paymentUrl }`. @@ -44,7 +44,7 @@ Works the same as the server-side `subscribe`, but without `customerId`. Returns ``` -## `customerPortal` +## customerPortal Opens the provider's customer portal. Returns `{ url }`. @@ -72,4 +72,4 @@ You can also pass an absolute URL like `https://example.com/custom`. ## Type safety -The client infers available plan IDs directly from your server instance type. If you pass an invalid `planId`, TypeScript catches it at compile time. See [TypeScript](/docs/concepts/typescript) for more on how type inference works across the stack. +The client infers available plan IDs directly from your server instance type. If you pass an invalid `planId`, TypeScript catches it at compile time. See [TypeScript](/docs/typescript) for more on how type inference works across the stack. diff --git a/apps/web/content/docs/concepts/meta.json b/apps/web/content/docs/concepts/meta.json deleted file mode 100644 index b2e609e4..00000000 --- a/apps/web/content/docs/concepts/meta.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "title": "Concepts", - "pages": [ - "plans-and-features", - "customers", - "subscriptions", - "entitlements", - "webhook-events", - "database", - "payment-providers", - "plugins", - "client", - "cli", - "typescript" - ] -} diff --git a/apps/web/content/docs/concepts/payment-providers.mdx b/apps/web/content/docs/concepts/payment-providers.mdx deleted file mode 100644 index 73c7c8bf..00000000 --- a/apps/web/content/docs/concepts/payment-providers.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Payment Providers -description: How PayKit abstracts provider integration and keeps provider-native IDs internal. ---- - -PayKit uses a provider abstraction to communicate with payment processors. Your app works with plans, customers, and subscriptions. PayKit translates those into provider-native operations behind the scenes. - -## How it works - -You install a provider adapter package and pass it to `createPayKit({ provider })`. The adapter handles all provider-specific API calls, webhook normalization, and product syncing. Your app code doesn't change if you swap providers. - -```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; - -export const paykit = createPayKit({ - // ... - provider: stripe({ - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), -}); -``` - -## Stripe - -Stripe is currently the primary supported provider. It covers subscriptions, usage-based billing, webhooks, and product syncing out of the box. - -See the [Stripe provider page](/docs/providers/stripe) for full setup instructions, webhook configuration, and available options. - -## Provider-native IDs stay internal - -Your app identifies customers and plans with its own IDs. PayKit maps them to Stripe customer IDs, product IDs, and price IDs internally. You never store or reference Stripe IDs in your app code. - -```ts -// Your app always uses its own IDs -await paykit.subscribe({ customerId: "user_123", planId: "pro" }); - -// PayKit resolves the Stripe customer and price internally -``` - - - This mapping means you can migrate to a different provider without changing any of your application code. - - -## Future providers - - - Support for additional providers including PayPal and regional PSPs is planned. The adapter interface is stable, so community-built providers will work with the same API. - diff --git a/apps/web/content/docs/concepts/customers.mdx b/apps/web/content/docs/customers.mdx similarity index 100% rename from apps/web/content/docs/concepts/customers.mdx rename to apps/web/content/docs/customers.mdx diff --git a/apps/web/content/docs/plugins/dashboard.mdx b/apps/web/content/docs/dashboard.mdx similarity index 100% rename from apps/web/content/docs/plugins/dashboard.mdx rename to apps/web/content/docs/dashboard.mdx diff --git a/apps/web/content/docs/concepts/database.mdx b/apps/web/content/docs/database.mdx similarity index 98% rename from apps/web/content/docs/concepts/database.mdx rename to apps/web/content/docs/database.mdx index fa4bc1b9..639512bb 100644 --- a/apps/web/content/docs/concepts/database.mdx +++ b/apps/web/content/docs/database.mdx @@ -51,7 +51,9 @@ PayKit creates tables prefixed with `paykit_`. The key ones are: `paykitjs push` applies any pending migrations. Run it on initial setup and whenever you update your plan configuration. - +```bash +npx paykitjs push +``` ## What's synced diff --git a/apps/web/content/docs/concepts/entitlements.mdx b/apps/web/content/docs/entitlements.mdx similarity index 100% rename from apps/web/content/docs/concepts/entitlements.mdx rename to apps/web/content/docs/entitlements.mdx diff --git a/apps/web/content/docs/flows/meta.json b/apps/web/content/docs/flows/meta.json deleted file mode 100644 index 2fc1f59f..00000000 --- a/apps/web/content/docs/flows/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Flows", - "pages": ["subscription-billing", "metered-usage"] -} diff --git a/apps/web/content/docs/get-started/index.mdx b/apps/web/content/docs/get-started/index.mdx deleted file mode 100644 index d891c3ba..00000000 --- a/apps/web/content/docs/get-started/index.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Introduction -description: Introduction to PayKit. ---- - -PayKit is an embedded billing framework for TypeScript apps. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced billing capabilities. Whether you need subscription management, usage-based billing, entitlement checks, or plan management across multiple providers, it lets you focus on building your product instead of wiring up payment provider APIs. - -## Features - -PayKit aims to be a complete billing framework. It provides a wide range of features out of the box and allows you to extend it with plugins. - - - -...and more to come! diff --git a/apps/web/content/docs/get-started/meta.json b/apps/web/content/docs/get-started/meta.json deleted file mode 100644 index ef4c767e..00000000 --- a/apps/web/content/docs/get-started/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Get Started", - "pages": ["index", "installation", "quickstart"] -} diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json deleted file mode 100644 index d2cb1d9b..00000000 --- a/apps/web/content/docs/guides/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Guides", - "pages": ["skills"] -} diff --git a/apps/web/content/docs/get-started/installation.mdx b/apps/web/content/docs/installation.mdx similarity index 83% rename from apps/web/content/docs/get-started/installation.mdx rename to apps/web/content/docs/installation.mdx index 49c68daa..d06038ef 100644 --- a/apps/web/content/docs/get-started/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -1,28 +1,25 @@ --- title: Installation -description: Install PayKit, configure your billing instance, and mount the route handler in your app. +description: how to install and configure PayKit in your app --- +## Steps + -## Install the package +Install the package Let's start by adding PayKit to your project: - - - - - - If you're using a separate client and server setup, make sure to install - the package in both apps. - +```bash +npm install paykitjs +``` -## Create the PayKit instance +Create the PayKit instance Create a file named `paykit.ts` anywhere in your app. @@ -42,30 +39,24 @@ export const paykit = createPayKit({ -## Configure provider +Configure Stripe -PayKit can operate with different payment providers. Here's an example with Stripe, but you can easily configure another one, like Polar, Creem, and custom! - -Install the provider adapter and plug it into your instance. - - +PayKit uses Stripe for billing. Pass your Stripe keys directly to the PayKit instance. ```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; - export const paykit = createPayKit({ // ... - provider: stripe({ // [!code highlight] + stripe: { // [!code highlight] secretKey: process.env.STRIPE_SECRET_KEY!, // [!code highlight] webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,// [!code highlight] - }),// [!code highlight] + },// [!code highlight] }); ``` -## Configure database +Configure database PayKit needs a database to store billing state, such as subscriptions. You can create a separate database, or simply plug it into the app's own db. @@ -83,13 +74,13 @@ export const paykit = createPayKit({ ``` - It works by creating a few tables prefixed with `paykit_`. You can learn more [here](/docs/concepts/database). + It works by creating a few tables prefixed with `paykit_`. You can learn more [here](/docs/database). -## Mount request handler +Mount request handler To handle webhooks and client API requests, you need to set up a request handler on your server. @@ -141,7 +132,7 @@ Create a new file or route in your framework's designated catch-all route handle -## Create client instance +Create client instance The client-side library helps you interact with the server. PayKit client sdk suitable for almost all modern frameworks, including React. @@ -173,7 +164,7 @@ export const paykit = createPayKit({ -## Define your products +Define your products Optionally. PayKit provides a code-first way to create your plans, and a very useful usage billing with `track()` and `report()` out of the box. @@ -227,21 +218,21 @@ export const paykit = createPayKit({ -## Push changes to DB +Push changes to DB PayKit includes a CLI tool to keep your database in sync with your configuration. - +```bash +npx paykitjs push +``` This applies database migrations and syncs your plan definitions to provider's products.
Run it once on setup, and every time after you change your products configuration. -
For production deployments, see the [CLI reference](/docs/concepts/cli#production-usage). +
For production deployments, see the [CLI reference](/docs/cli#production-usage).
- __You're now ready to use PayKit in your app!__ 🚀 -
diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx new file mode 100644 index 00000000..336ff731 --- /dev/null +++ b/apps/web/content/docs/introduction.mdx @@ -0,0 +1,12 @@ +--- +title: Introduction +description: Introduction to PayKit. +--- + +PayKit is an embedded Stripe billing framework for TypeScript apps. It provides subscription management, usage-based billing, entitlement checks, and plan management so you can focus on building your product instead of wiring up Stripe lifecycle code. + +## Features + +PayKit aims to be a complete billing framework. It provides a wide range of features out of the box and allows you to extend it with plugins. + + diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index 8bb3411d..78e7d1e5 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -2,16 +2,24 @@ "root": true, "pages": [ "---Get Started---", - "get-started", + "introduction", + "installation", + "quickstart", "---Concepts---", - "concepts", + "plans-and-features", + "customers", + "subscriptions", + "entitlements", + "webhook-events", + "database", + "plugins", + "client", + "cli", + "typescript", "---Flows---", - "flows", - "---Providers---", - "providers", + "subscription-billing", + "metered-usage", "---Plugins---", - "plugins", - "---Guides---", - "guides" + "dashboard" ] } diff --git a/apps/web/content/docs/flows/metered-usage.mdx b/apps/web/content/docs/metered-usage.mdx similarity index 91% rename from apps/web/content/docs/flows/metered-usage.mdx rename to apps/web/content/docs/metered-usage.mdx index 69f028a6..e4750405 100644 --- a/apps/web/content/docs/flows/metered-usage.mdx +++ b/apps/web/content/docs/metered-usage.mdx @@ -9,7 +9,7 @@ This guide walks through implementing usage-based billing: how to define metered -## Define a metered feature +Define a metered feature Metered features track usage against a limit. Define one with `type: "metered"`. @@ -23,7 +23,7 @@ const messages = feature({ id: "messages", type: "metered" }); -## Include in plans with different limits +Include in plans with different limits Pass the feature into each plan with a `limit` and `reset` interval. @@ -55,7 +55,7 @@ Free customers get 100 messages per month; Pro customers get 2,000. -## Check before consuming +Check before consuming Call `check` before performing the action. It returns `allowed` (whether the customer has remaining balance) and `balance` (how many units are left). @@ -72,7 +72,7 @@ If `allowed` is `false`, the customer has hit their limit. Return early instead -## Perform the action +Perform the action Only run the actual work if `allowed` is `true`. @@ -88,7 +88,7 @@ const response = await generateChatResponse(input); -## Report usage +Report usage After the action succeeds, call `report` to decrement the customer's balance. @@ -106,7 +106,7 @@ Pass `amount` matching however many units the action consumed. For most cases th -## Balance resets +Balance resets PayKit uses lazy resets. When the reset period passes, it doesn't reset balances proactively. Instead, the next `check` or `report` call detects that the period has expired and resets the balance automatically before returning. diff --git a/apps/web/content/docs/concepts/plans-and-features.mdx b/apps/web/content/docs/plans-and-features.mdx similarity index 100% rename from apps/web/content/docs/concepts/plans-and-features.mdx rename to apps/web/content/docs/plans-and-features.mdx diff --git a/apps/web/content/docs/concepts/plugins.mdx b/apps/web/content/docs/plugins.mdx similarity index 97% rename from apps/web/content/docs/concepts/plugins.mdx rename to apps/web/content/docs/plugins.mdx index c83bc8e4..dae4b930 100644 --- a/apps/web/content/docs/concepts/plugins.mdx +++ b/apps/web/content/docs/plugins.mdx @@ -24,7 +24,9 @@ Each plugin mounts its own endpoints under the PayKit API base path automaticall `@paykitjs/dash` adds an embedded billing dashboard your users can access directly from your app. It shows subscriptions, invoices, and lets customers manage their payment methods. - +```bash +npm install @paykitjs/dash +``` Pass an `authorize` function to control who can access it. Without `authorize`, the dashboard is open to any authenticated request. diff --git a/apps/web/content/docs/plugins/meta.json b/apps/web/content/docs/plugins/meta.json deleted file mode 100644 index d36d5294..00000000 --- a/apps/web/content/docs/plugins/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Plugins", - "pages": ["dashboard"] -} diff --git a/apps/web/content/docs/providers/creem.mdx b/apps/web/content/docs/providers/creem.mdx deleted file mode 100644 index 1bac8358..00000000 --- a/apps/web/content/docs/providers/creem.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Creem -description: Creem is a planned provider for teams that need a modern merchant workflow with the same PayKit API. ---- - - - Creem support is planned. This page documents the intended shape, not a released adapter. - - -Creem fits the broader PayKit goal of letting apps depend on one billing model even when the -underlying provider differs. - -## Planned setup - -```ts -import { creem } from "paykitjs/providers/creem"; - -const provider = creem({ - apiKey: process.env.CREEM_API_KEY!, - webhookSecret: process.env.CREEM_WEBHOOK_SECRET!, -}); -``` - -## Planned focus - -- provider-hosted checkout -- webhook verification and event normalization -- customer and charge sync into the PayKit local model - -- provider-hosted checkout -- webhook verification and event normalization -- customer and charge sync into the PayKit local model diff --git a/apps/web/content/docs/providers/lemonsqueezy.mdx b/apps/web/content/docs/providers/lemonsqueezy.mdx deleted file mode 100644 index 7e25d37e..00000000 --- a/apps/web/content/docs/providers/lemonsqueezy.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Lemon Squeezy -description: Lemon Squeezy is planned as a future provider adapter in the broader docs tree. ---- - - - Lemon Squeezy is not in the first shipping set. This page exists as a docs placeholder for the - future provider matrix. - - -## Planned setup - -```ts -import { lemonSqueezy } from "paykitjs/providers/lemonsqueezy"; - -const provider = lemonSqueezy({ - apiKey: process.env.LEMONSQUEEZY_API_KEY!, - webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!, -}); -``` - -## Expected role - -The eventual adapter should expose the same app-facing PayKit primitives for checkout, synced -customers, charges, and normalized webhook events. diff --git a/apps/web/content/docs/providers/meta.json b/apps/web/content/docs/providers/meta.json deleted file mode 100644 index 1778c933..00000000 --- a/apps/web/content/docs/providers/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Providers", - "pages": ["stripe", "polar", "paypal", "creem", "paddle"] -} diff --git a/apps/web/content/docs/providers/paddle.mdx b/apps/web/content/docs/providers/paddle.mdx deleted file mode 100644 index e70d77ee..00000000 --- a/apps/web/content/docs/providers/paddle.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Paddle -description: Paddle is a future provider target for teams that need the same orchestration layer on top. ---- - - - Paddle is listed as a future integration. The examples below are placeholders for the docs tree. - - -Paddle would fit the same contract as other adapters: provider-native execution with PayKit-owned -local billing state. - -## Planned setup - -```ts -import { paddle } from "paykitjs/providers/paddle"; - -const provider = paddle({ - apiKey: process.env.PADDLE_API_KEY!, - webhookSecret: process.env.PADDLE_WEBHOOK_SECRET!, -}); -``` - -## Expected MVP coverage - -- checkout creation -- webhook verification -- local sync for customers, payment methods when supported, and charges - -- checkout creation -- webhook verification -- local sync for customers, payment methods when supported, and charges diff --git a/apps/web/content/docs/providers/paypal.mdx b/apps/web/content/docs/providers/paypal.mdx deleted file mode 100644 index 6ada1245..00000000 --- a/apps/web/content/docs/providers/paypal.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: PayPal -description: PayPal is a planned first-party provider with the same normalized API shape as Stripe. ---- - - - PayPal support is planned, but the API examples on this page are still mock documentation. - - -PayPal matters because many SaaS teams need it alongside Stripe for regional coverage and customer -preference. PayKit's goal is to keep the app-facing shape the same even when provider capabilities -vary. - -## Planned setup - -```ts -import { paypal } from "paykitjs/providers/paypal"; - -const provider = paypal({ - clientId: process.env.PAYPAL_CLIENT_ID!, - clientSecret: process.env.PAYPAL_CLIENT_SECRET!, - webhookId: process.env.PAYPAL_WEBHOOK_ID!, -}); -``` - -## Planned scope - -- hosted checkout flows -- customer mapping and provider account sync -- saved payment methods where the provider flow supports them -- webhook normalization into the same PayKit event model - -## Design goal - -Your business code should still call `paykit.checkout.create(...)` or -`paykit.paymentMethod.list(...)` without branching on provider-native webhook names or customer IDs. diff --git a/apps/web/content/docs/providers/polar.mdx b/apps/web/content/docs/providers/polar.mdx deleted file mode 100644 index 4a653336..00000000 --- a/apps/web/content/docs/providers/polar.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Polar -description: Configure Polar for PayKit, set up webhooks, sync products, and use the customer portal. ---- - -Polar is a developer-first payment platform. The `@paykitjs/polar` adapter handles all Polar API interactions, webhook processing, and product syncing. - -## Installation - - - -## Configuration - -Pass the `polar()` adapter to `createPayKit` with your access token and webhook secret. - -```ts title="paykit.ts" -import { polar } from "@paykitjs/polar"; -import { createPayKit } from "paykitjs"; - -export const paykit = createPayKit({ - // ... - provider: polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN!, - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - server: "sandbox", // or "production" - }), -}); -``` - -## Environment variables - -Add these variables to your `.env` file: - -```bash title=".env" -POLAR_ACCESS_TOKEN=polar_oat_... -POLAR_WEBHOOK_SECRET=... -``` - -- `POLAR_ACCESS_TOKEN`: create one in [Polar Settings](https://polar.sh/settings) under **Access Tokens**. The token needs the following scopes: `products:read`, `products:write`, `customers:read`, `customers:write`, `customer_sessions:write`, `subscriptions:read`, `subscriptions:write`, `checkouts:write`, `organizations:read`, `organizations:write`. -- `POLAR_WEBHOOK_SECRET`: generated when you create a webhook endpoint. See the section below. - -## Webhook setup - -In the Polar Dashboard, go to **Settings > Webhooks** and create a new endpoint pointing to: - -``` -https://your-app.com/paykit/webhook -``` - -Enable the following events: - -- `checkout.created`, `checkout.updated` -- `subscription.created`, `subscription.updated`, `subscription.active`, `subscription.canceled`, `subscription.uncanceled`, `subscription.revoked` - -You can also select all events. PayKit silently ignores any events it doesn't need. After saving, Polar displays the signing secret. Copy it as your `POLAR_WEBHOOK_SECRET`. - - - Polar uses the [Standard Webhooks](https://www.standardwebhooks.com) specification for signature verification and event deduplication. - - -## Local development - -Use the Polar CLI to forward webhook events to your local server: - -```bash -polar listen http://localhost:3000/paykit/webhook -``` - -The CLI prints a webhook signing secret at startup. Use that as `POLAR_WEBHOOK_SECRET` in your local `.env`. - - - Install the Polar CLI with `curl -fsSL https://polar.sh/install.sh | bash`. You'll need to run `polar login` once to authenticate. - - -## Product syncing - -`paykitjs push` creates and updates Polar products to match your plan definitions. You don't need to touch the Polar Dashboard for product management. - - - -On every push, PayKit automatically: - -- Creates or updates products to match your plans -- Sets all products to **private** visibility (only purchasable via PayKit checkout, not the customer portal) -- Archives orphan products not managed by PayKit -- Configures your Polar organization settings (multiple subscriptions enabled, portal plan changes disabled) - - - Run this once on setup, and again every time you change your plans or pricing. - - -## Customer portal - -PayKit can open Polar's customer portal so users can view their subscriptions and invoices. - - - - ```ts - const { url } = await paykit.customerPortal({ - customerId: "user_123", - returnUrl: "https://myapp.com/billing", - }); - - // redirect the user to `url` - ``` - - - ```ts - const { url } = await paykitClient.customerPortal({ - returnUrl: window.location.href, - }); - - window.location.href = url; - ``` - - - - - Plan changes are disabled in the portal automatically by PayKit. Subscriptions should be managed through PayKit's API to keep state in sync. - - -## Dedicated account - -PayKit requires full ownership of your Polar account. The `push` command will block if it detects customers on Polar that are not managed by PayKit. Use a dedicated Polar organization for your PayKit integration. - -## Sandbox mode - -Pass `server: "sandbox"` to test with Polar's sandbox environment. This uses separate sandbox API endpoints and test data. - -```ts -provider: polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN!, - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - server: "sandbox", -}), -``` - - - Use a sandbox access token when testing. Sandbox and production are completely isolated in Polar. - diff --git a/apps/web/content/docs/providers/stripe.mdx b/apps/web/content/docs/providers/stripe.mdx deleted file mode 100644 index 948a413a..00000000 --- a/apps/web/content/docs/providers/stripe.mdx +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: Stripe -description: Configure Stripe for PayKit, set up webhooks, sync products, and use the customer portal. ---- - -Stripe is PayKit's primary payment provider. The `@paykitjs/stripe` adapter handles all Stripe API interactions, webhook processing, and product syncing. - -## Installation - - - -## Configuration - -Pass the `stripe()` adapter to `createPayKit` with your secret key and webhook secret. - -```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; -import { createPayKit } from "paykitjs"; - -export const paykit = createPayKit({ - // ... - provider: stripe({ - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), -}); -``` - -## Environment variables - -Add these two variables to your `.env` file: - -```bash title=".env" -STRIPE_SECRET_KEY=sk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... -``` - -- `STRIPE_SECRET_KEY`: find this in [Stripe Dashboard](https://dashboard.stripe.com) under **Developers > API keys**. -- `STRIPE_WEBHOOK_SECRET`: generated when you create a webhook endpoint. See the section below. - -## Webhook setup - -In the Stripe Dashboard, go to **Developers > Webhooks** and create a new endpoint pointing to: - -``` -https://your-app.com/paykit/webhook -``` - -Select the following events when creating the endpoint: - -- `checkout.session.completed` -- `customer.subscription.created` -- `customer.subscription.updated` -- `customer.subscription.deleted` -- `invoice.created` -- `invoice.finalized` -- `invoice.paid` -- `invoice.payment_failed` -- `invoice.updated` -- `payment_method.detached` - - - Stripe discourages selecting all events as it may cause performance issues. Only the events listed above are required by PayKit. - - -After saving, Stripe displays the signing secret. Copy it as your `STRIPE_WEBHOOK_SECRET`. - -## Local development - -Use the Stripe CLI to forward webhook events to your local server: - -```bash -stripe listen --forward-to localhost:3000/paykit/webhook -``` - -The CLI prints a webhook signing secret at startup. Use that as `STRIPE_WEBHOOK_SECRET` in your local `.env`. - - - Install the Stripe CLI from [stripe.com/docs/stripe-cli](https://stripe.com/docs/stripe-cli). You'll need to run `stripe login` once to authenticate. - - -## Product syncing - -`paykitjs push` creates and updates Stripe products and prices to match your plan definitions. You don't need to touch the Stripe Dashboard for product management. - - - - - Run this once on setup, and again every time you change your plans or pricing. - - -## Customer portal - -PayKit can open Stripe's built-in customer portal so users can manage payment methods, invoices, and subscriptions. - - - - ```ts - const { url } = await paykit.customerPortal({ - customerId: "user_123", - returnUrl: "https://myapp.com/billing", - }); - - // redirect the user to `url` - ``` - - - ```ts - const { url } = await paykitClient.customerPortal({ - returnUrl: window.location.href, - }); - - window.location.href = url; - ``` - - - -The portal is hosted by Stripe, so no additional UI work is needed on your end. - -## Testing mode - -Enable `testing` on your PayKit instance to use Stripe test clocks during development. This lets you simulate time-based billing events like renewals and trial expirations. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - testing: { - enabled: true, - }, -}); -``` - - - Use your Stripe test mode keys (`sk_test_...`) when testing. Test mode and live mode are completely isolated in Stripe. - - -## API version - -PayKit pins the Stripe SDK to a known-good API version so upstream changes don't silently break your integration. Override it with `apiVersion` if you need a newer version, for example to opt into a preview feature. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - provider: stripe({ - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - apiVersion: "2026-03-04.preview", - }), -}); -``` - -The pinned default is exported as `PAYKIT_STRIPE_API_VERSION` if you want to reference it in code. - -## Managed Payments - -Stripe Managed Payments is a preview feature where Stripe takes over tax calculation, payment method selection, and billing address collection on subscription checkout sessions. Enable it with `managedPayments: true`, and set `apiVersion` to the preview version that supports it. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - provider: stripe({ - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - apiVersion: "2026-03-04.preview", - managedPayments: true, - }), -}); -``` - - - Managed Payments is a Stripe preview feature and the API version required may change. Review the [Stripe Managed Payments docs](https://docs.stripe.com/payments/managed-payments) before enabling in production. Note: digital products only, no shipping, and several Checkout parameters (`automatic_tax`, `payment_method_types`, Connect fields) are not supported in this mode. - diff --git a/apps/web/content/docs/get-started/quickstart.mdx b/apps/web/content/docs/quickstart.mdx similarity index 88% rename from apps/web/content/docs/get-started/quickstart.mdx rename to apps/web/content/docs/quickstart.mdx index d9d77b9b..23ad8094 100644 --- a/apps/web/content/docs/get-started/quickstart.mdx +++ b/apps/web/content/docs/quickstart.mdx @@ -3,7 +3,7 @@ title: Quickstart description: Getting started with PayKit. --- -This page assumes you have completed the [installation](/docs/get-started/installation) steps and have a running PayKit instance with plans defined. +This page assumes you have completed the [installation](/docs/installation) steps and have a running PayKit instance with plans defined. ## Sync a customer @@ -146,7 +146,6 @@ export const paykit = createPayKit({ ## Next steps -- [Plans & Features](/docs/concepts/plans-and-features) - plan groups, defaults, and feature types -- [Subscriptions](/docs/concepts/subscriptions) - upgrade, downgrade, and cancellation behavior -- [Entitlements](/docs/concepts/entitlements) - access checks and metered usage -- [Stripe](/docs/providers/stripe) - provider configuration +- [Plans & Features](/docs/plans-and-features) - plan groups, defaults, and feature types +- [Subscriptions](/docs/subscriptions) - upgrade, downgrade, and cancellation behavior +- [Entitlements](/docs/entitlements) - access checks and metered usage diff --git a/apps/web/content/docs/guides/skills.mdx b/apps/web/content/docs/skills.mdx similarity index 87% rename from apps/web/content/docs/guides/skills.mdx rename to apps/web/content/docs/skills.mdx index 776ebf70..b02c6832 100644 --- a/apps/web/content/docs/guides/skills.mdx +++ b/apps/web/content/docs/skills.mdx @@ -3,9 +3,9 @@ title: Skills description: Agent skills for coding assistants to set up and configure billing with PayKit. --- -[Agent skills](https://agentskills.io) are portable instruction files (for example `SKILL.md`) that teach your coding agent project conventions, safe patterns, and where to look in the docs. The **PayKit** skill pack lives in the [`getpaykit/skills`](https://github.com/getpaykit/skills) repository. +[Agent skills](https://agentskills.io) are portable instruction files (for example `SKILL.md`) that teach your coding agent project conventions, safe patterns, and where to look in the docs. The **PayKit** skill pack lives in the [getpaykit/skills](https://github.com/getpaykit/skills) repository. -Install with the [`skills` CLI](https://www.npmjs.com/package/skills) (uses `npx` so nothing global is required): +Install with the [skills CLI](https://www.npmjs.com/package/skills) (uses `npx` so nothing global is required): ```bash title="terminal" npx skills add getpaykit/skills diff --git a/apps/web/content/docs/flows/subscription-billing.mdx b/apps/web/content/docs/subscription-billing.mdx similarity index 92% rename from apps/web/content/docs/flows/subscription-billing.mdx rename to apps/web/content/docs/subscription-billing.mdx index cb39eb1e..c57832e2 100644 --- a/apps/web/content/docs/flows/subscription-billing.mdx +++ b/apps/web/content/docs/subscription-billing.mdx @@ -7,7 +7,7 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Define plans + Define plans Start by defining your plans. A typical setup has a free tier as the default and one or more paid tiers in the same group. @@ -48,11 +48,11 @@ This guide walks through a complete subscription billing flow: defining plans, s }); ``` - For the full reference on plan groups, feature types, and pricing options, see [Plans & Features](/docs/concepts/plans-and-features). + For the full reference on plan groups, feature types, and pricing options, see [Plans & Features](/docs/plans-and-features). - ## Create a customer + Create a customer Before a customer can subscribe, they need to exist in PayKit. @@ -93,7 +93,7 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Subscribe to a paid plan + Subscribe to a paid plan Call `subscribe()` with the target plan ID. For paid plans without a saved payment method, it returns a `paymentUrl` for checkout. @@ -138,17 +138,17 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Handle the webhook + Handle the webhook After checkout, your payment provider sends a webhook to PayKit's endpoint. PayKit verifies the signature, syncs the subscription locally, and fires a `customer.updated` event. This is fully automatic. You don't need to manually process Stripe events or update your database. By the time `customer.updated` fires, the customer's subscriptions and entitlements are already up to date. - See [Webhook Events](/docs/concepts/webhook-events) for details on how PayKit processes and deduplicates incoming events. + See [Webhook Events](/docs/webhook-events) for details on how PayKit processes and deduplicates incoming events. - ## Check entitlements + Check entitlements Use `check()` to gate features based on the customer's active plan. @@ -169,11 +169,11 @@ This guide walks through a complete subscription billing flow: defining plans, s }); ``` - For metered features, `check()` also returns a `balance` with `remaining`, `limit`, and `resetAt`. See [Entitlements](/docs/concepts/entitlements) for the full pattern including `report()`. + For metered features, `check()` also returns a `balance` with `remaining`, `limit`, and `resetAt`. See [Entitlements](/docs/entitlements) for the full pattern including `report()`. - ## Upgrade + Upgrade Upgrading moves the customer to a higher-priced plan in the same group. It takes effect immediately. @@ -189,7 +189,7 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Downgrade + Downgrade Downgrading moves the customer to a lower-priced plan. The current plan stays active until the end of the billing period, then the target plan activates automatically. @@ -205,7 +205,7 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Cancel + Cancel Cancellation works the same way as a downgrade: subscribe the customer to the default free plan. @@ -228,7 +228,7 @@ This guide walks through a complete subscription billing flow: defining plans, s - ## Listen to changes + Listen to changes Add `on` handlers to your PayKit instance to react to any billing change. `customer.updated` fires after every subscription or entitlement update, including webhook-driven changes. diff --git a/apps/web/content/docs/concepts/subscriptions.mdx b/apps/web/content/docs/subscriptions.mdx similarity index 100% rename from apps/web/content/docs/concepts/subscriptions.mdx rename to apps/web/content/docs/subscriptions.mdx diff --git a/apps/web/content/docs/concepts/typescript.mdx b/apps/web/content/docs/typescript.mdx similarity index 99% rename from apps/web/content/docs/concepts/typescript.mdx rename to apps/web/content/docs/typescript.mdx index 87b83078..5375733d 100644 --- a/apps/web/content/docs/concepts/typescript.mdx +++ b/apps/web/content/docs/typescript.mdx @@ -32,7 +32,7 @@ await paykit.subscribe({ customerId: "user_123", planId: "typo" }); Your editor's autocomplete also knows every valid plan and feature ID across the whole app. -## The `$infer` helper +## The $infer helper `paykit.$infer` exposes the inferred union types so you can use them elsewhere in your app without re-declaring them. diff --git a/apps/web/content/docs/concepts/webhook-events.mdx b/apps/web/content/docs/webhook-events.mdx similarity index 94% rename from apps/web/content/docs/concepts/webhook-events.mdx rename to apps/web/content/docs/webhook-events.mdx index 741546c2..7f427c7d 100644 --- a/apps/web/content/docs/concepts/webhook-events.mdx +++ b/apps/web/content/docs/webhook-events.mdx @@ -32,7 +32,7 @@ export const paykit = createPayKit({ }); ``` -## `customer.updated` +## customer.updated Fires after any subscription or entitlement change. Use it to sync billing state into your own data layer, invalidate caches, or trigger downstream logic. @@ -60,7 +60,7 @@ export const paykit = createPayKit({ ## Webhook route -The webhook endpoint is exposed by paykit handler at `/paykit/webhook` by default. See the [installation page](/docs/get-started/installation) for details on mounting it. +The webhook endpoint is exposed by paykit handler at `/paykit/webhook` by default. See the [installation page](/docs/installation) for details on mounting it. ## Idempotency diff --git a/apps/web/content/drafts/docs-index.mdx b/apps/web/content/drafts/docs-index.mdx index 97f7e4a7..1b0f2c26 100644 --- a/apps/web/content/drafts/docs-index.mdx +++ b/apps/web/content/drafts/docs-index.mdx @@ -34,8 +34,8 @@ Subscriptions are intentionally **not** the core docs story right now. - [Code](/docs/code/database) The local billing-state model plus the MVP APIs for checkout, customers, payment methods, charges, and webhooks. -- [Providers](/docs/providers/stripe) - Provider-specific setup notes and current integration status. +- [Payment Providers](/docs/concepts/payment-providers) + Stripe setup notes and current integration status. - [Code](/docs/code/sdks/server-sdk) The planned server SDK, future React SDK direction, and framework handler examples. diff --git a/apps/web/mdx-components.tsx b/apps/web/mdx-components.tsx index 9bc2bcb1..fd718717 100644 --- a/apps/web/mdx-components.tsx +++ b/apps/web/mdx-components.tsx @@ -1,20 +1,10 @@ -import { Callout } from "fumadocs-ui/components/callout"; -import { Card, Cards } from "fumadocs-ui/components/card"; -import { Step, Steps } from "fumadocs-ui/components/steps"; -import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; +import { docsMdxComponents } from "@/components/docs/docs-mdx-components"; + export function useMDXComponents(components?: MDXComponents): MDXComponents { return { - ...defaultMdxComponents, + ...docsMdxComponents, ...components, - Callout, - Card, - Cards, - Step, - Steps, - Tab, - Tabs, }; } diff --git a/apps/web/next.config.js b/apps/web/next.config.js index fa766063..a19c60c1 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -8,6 +8,36 @@ import "./src/env.js"; const withMDX = createMDX(); const currentDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(currentDir, "../.."); +const remixLucideShim = join(currentDir, "src/lib/lucide-react-remix-shim.ts"); + +const docsRedirects = [ + { source: "/docs", destination: "/docs/introduction", permanent: true }, + { source: "/docs/get-started", destination: "/docs/introduction", permanent: true }, + { source: "/docs/get-started/installation", destination: "/docs/installation", permanent: true }, + { source: "/docs/get-started/quickstart", destination: "/docs/quickstart", permanent: true }, + { + source: "/docs/concepts/plans-and-features", + destination: "/docs/plans-and-features", + permanent: true, + }, + { source: "/docs/concepts/customers", destination: "/docs/customers", permanent: true }, + { source: "/docs/concepts/subscriptions", destination: "/docs/subscriptions", permanent: true }, + { source: "/docs/concepts/entitlements", destination: "/docs/entitlements", permanent: true }, + { source: "/docs/concepts/webhook-events", destination: "/docs/webhook-events", permanent: true }, + { source: "/docs/concepts/database", destination: "/docs/database", permanent: true }, + { source: "/docs/concepts/plugins", destination: "/docs/plugins", permanent: true }, + { source: "/docs/concepts/client", destination: "/docs/client", permanent: true }, + { source: "/docs/concepts/cli", destination: "/docs/cli", permanent: true }, + { source: "/docs/concepts/typescript", destination: "/docs/typescript", permanent: true }, + { + source: "/docs/flows/subscription-billing", + destination: "/docs/subscription-billing", + permanent: true, + }, + { source: "/docs/flows/metered-usage", destination: "/docs/metered-usage", permanent: true }, + { source: "/docs/plugins/dashboard", destination: "/docs/dashboard", permanent: true }, + { source: "/docs/guides/skills", destination: "/docs/skills", permanent: true }, +]; /** @type {import("next").NextConfig} */ const config = { @@ -17,6 +47,13 @@ const config = { outputFileTracingRoot: repoRoot, turbopack: { root: repoRoot, + resolveAlias: { + "lucide-react": "./src/lib/lucide-react-remix-shim.ts", + }, + }, + webpack: (config) => { + config.resolve.alias["lucide-react"] = remixLucideShim; + return config; }, experimental: { optimizePackageImports: [ @@ -30,6 +67,7 @@ const config = { ], }, redirects: async () => [ + ...docsRedirects, { source: "/github", destination: "https://github.com/getpaykit/paykit", permanent: false }, { source: "/discord", destination: "https://discord.gg/nzy9NPpFNU", permanent: false }, { source: "/x", destination: "https://x.com/paykit_sh", permanent: false }, @@ -43,6 +81,8 @@ const config = { destination: "https://github.com/orgs/getpaykit/projects/1", permanent: false, }, + { source: "/donate", destination: "/sponsor", permanent: true }, + { source: "/sponsors", destination: "/sponsor", permanent: true }, ], }; diff --git a/apps/web/package.json b/apps/web/package.json index 4d970ae2..9739e58e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.5", "@t3-oss/env-nextjs": "^0.12.0", + "@tanstack/react-hotkeys": "^0.10.0", "@types/mdx": "^2.0.13", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^2.0.0", @@ -30,9 +31,9 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.34.3", - "fumadocs-core": "^16.7.11", - "fumadocs-mdx": "^14.2.11", - "fumadocs-ui": "^16.7.11", + "fumadocs-core": "^16.9.3", + "fumadocs-mdx": "^15.0.10", + "fumadocs-ui": "^16.9.3", "geist": "^1.3.1", "input-otp": "^1.4.2", "lucide-react": "^0.575.0", diff --git a/apps/web/source.config.ts b/apps/web/source.config.ts index 784fb0c1..3a6ea149 100644 --- a/apps/web/source.config.ts +++ b/apps/web/source.config.ts @@ -1,3 +1,4 @@ +import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins/rehype-code"; import { defineConfig, defineDocs } from "fumadocs-mdx/config"; import { shikiThemes } from "./src/lib/shiki-themes"; @@ -15,6 +16,14 @@ export default defineConfig({ mdxOptions: { rehypeCodeOptions: { themes: shikiThemes, + transformers: [ + ...(rehypeCodeDefaultOptions.transformers ?? []), + { + pre(node) { + node.properties["data-language"] = this.options.lang; + }, + }, + ], }, }, }); diff --git a/apps/web/src/app/(marketing)/blog/page.tsx b/apps/web/src/app/(marketing)/blog/page.tsx new file mode 100644 index 00000000..c3887401 --- /dev/null +++ b/apps/web/src/app/(marketing)/blog/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { RiArrowLeftLine } from "react-icons/ri"; + +import { Section, SectionContent } from "@/components/layout/section"; +import { Button } from "@/components/ui/button"; + +/** + * Blog placeholder SEO metadata. + * + * @type {Metadata} + * Contains the page title, description, and canonical route. + */ +export const metadata: Metadata = { + title: "Blog", + description: "PayKit blog is coming soon.", + alternates: { + canonical: "/blog", + }, +}; + +/** Marketing blog landing page placeholder. + * + * @returns JSX.Element + */ +export default function BlogPage() { + return ( +
+
+ +
+

+ Coming soon +

+

+ Blog is not ready yet +

+

+ Notes, guides, engineering deep dives, and product updates will land here soon. +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(marketing)/contact/contact-form.tsx b/apps/web/src/app/(marketing)/contact/contact-form.tsx index 36dd61df..54a7aebb 100644 --- a/apps/web/src/app/(marketing)/contact/contact-form.tsx +++ b/apps/web/src/app/(marketing)/contact/contact-form.tsx @@ -1,8 +1,8 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Check, Loader2 } from "lucide-react"; import { useActionState } from "react"; +import { RiCheckLine, RiLoader4Line } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,7 +30,7 @@ export function ContactForm() { className="flex flex-col items-center gap-3 py-12 text-center" >
- +

We received your message.

We will get back to you as soon as possible.

@@ -87,7 +87,7 @@ export function ContactForm() { {state?.error &&

{state.error}

} )} diff --git a/apps/web/src/app/(marketing)/donate/page.tsx b/apps/web/src/app/(marketing)/donate/page.tsx deleted file mode 100644 index f428400f..00000000 --- a/apps/web/src/app/(marketing)/donate/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; - -import { Section, SectionContent } from "@/components/layout/section"; -import { FooterSection } from "@/components/sections/footer-section"; - -const donateUrl = "https://opencollective.com/maxktz"; - -export const metadata: Metadata = { - title: "Donate", - description: "Support PayKit development through Open Collective.", - alternates: { - canonical: "/donate", - }, -}; - -export default function DonatePage() { - return ( -
- - -
- -
-
-

- Redirecting to Open Collective... -

-

- Support ongoing PayKit development on Open Collective. If you are not redirected - automatically, use the fallback link below. -

-
- -
- - Continue to Open Collective - -
-
-
-
- - -
- ); -} diff --git a/apps/web/src/app/(marketing)/layout.tsx b/apps/web/src/app/(marketing)/layout.tsx index 520afabd..b54c4f19 100644 --- a/apps/web/src/app/(marketing)/layout.tsx +++ b/apps/web/src/app/(marketing)/layout.tsx @@ -1,18 +1,15 @@ import type { ReactNode } from "react"; -import { CommandMenuProvider } from "@/components/command-menu"; import { NavigationBar } from "@/components/layout/navigation-bar"; import { PageTransition } from "@/components/layout/page-transition"; export default function MarketingLayout({ children }: { children: ReactNode }) { return ( - -
- -
- {children} -
-
-
+
+ +
+ {children} +
+
); } diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index e648b2ee..bbb8d394 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -1,6 +1,6 @@ import { CTASection } from "@/components/sections/cta-section"; import { DemoSection } from "@/components/sections/demo"; -import { FeaturesSection } from "@/components/sections/features-section"; +import { FeedbackSection } from "@/components/sections/feedback-section"; import { FooterSection } from "@/components/sections/footer-section"; import { HeroSection } from "@/components/sections/hero-section"; import { demoSnippets } from "@/components/sections/readme-code-content"; @@ -17,7 +17,7 @@ export default function HomePage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} /> ))} -
+
, }} /> - +
diff --git a/apps/web/src/app/(marketing)/sponsor/page.tsx b/apps/web/src/app/(marketing)/sponsor/page.tsx new file mode 100644 index 00000000..9b555eb7 --- /dev/null +++ b/apps/web/src/app/(marketing)/sponsor/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { RiArrowLeftLine } from "react-icons/ri"; + +import { Section, SectionContent } from "@/components/layout/section"; +import { Button } from "@/components/ui/button"; + +export const metadata: Metadata = { + title: "Sponsors", + description: "PayKit sponsors page is coming soon.", + alternates: { + canonical: "/sponsor", + }, +}; + +export default function SponsorPage() { + return ( +
+
+ +
+

+ Coming soon +

+

+ Sponsors page is not ready yet +

+

+ Sponsor information and acknowledgements will be available soon. +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/api/og/[[...slug]]/route.tsx b/apps/web/src/app/api/og/[[...slug]]/route.tsx deleted file mode 100644 index af140200..00000000 --- a/apps/web/src/app/api/og/[[...slug]]/route.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { notFound } from "next/navigation"; -import { ImageResponse } from "next/og"; - -import { source } from "@/lib/source"; - -export function generateStaticParams() { - return source.generateParams(); -} - -const ogBlankDataUrl = `data:image/png;base64,${( - await readFile(join(process.cwd(), "public", "brand", "og-blank.png")) -).toString("base64")}`; - -export const GET = async (req: Request, { params }: { params: Promise<{ slug?: string[] }> }) => { - try { - let title: string; - const { slug } = await params; - - if (!slug || slug.length === 0) { - title = "Documentation"; - } else { - const page = source.getPage(slug ?? []); - if (!page) notFound(); - title = page.data.title ?? "Documentation"; - } - - return new ImageResponse( -
- -
-

- {title} -

-
-
, - { - width: 1200, - height: 600, - }, - ); - } catch { - return new Response(`Failed to generate the image`, { - status: 500, - }); - } -}; diff --git a/apps/web/src/app/docs/[[...slug]]/page.tsx b/apps/web/src/app/docs/[[...slug]]/page.tsx index 09cc1f89..3669104b 100644 --- a/apps/web/src/app/docs/[[...slug]]/page.tsx +++ b/apps/web/src/app/docs/[[...slug]]/page.tsx @@ -1,36 +1,23 @@ -import { Callout as BaseCallout } from "fumadocs-ui/components/callout"; -import { Card, Cards } from "fumadocs-ui/components/card"; -import { Step, Steps } from "fumadocs-ui/components/steps"; -import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page"; -import defaultMdxComponents from "fumadocs-ui/mdx"; import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; -import type { ComponentPropsWithoutRef } from "react"; +import { notFound } from "next/navigation"; import { CopyMarkdownButton } from "@/components/docs/copy-markdown-button"; -import { Features } from "@/components/docs/features"; -import { PackageInstall, PackageRun } from "@/components/docs/package-command"; -import { TocFooter } from "@/components/docs/toc-footer"; +import { docsMdxComponents } from "@/components/docs/docs-mdx-components"; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "@/components/docs/docs-page"; +import { PackageManagerProvider } from "@/components/docs/package-command"; +import { fallbackPackageManager } from "@/components/docs/package-manager-state"; import type { SourcePage } from "@/lib/source"; import { source } from "@/lib/source"; -import { cn } from "@/lib/utils"; interface DocsPageProps { params: Promise<{ slug?: string[] }>; } -function Callout(props: ComponentPropsWithoutRef) { - return ; -} +export const revalidate = false; export default async function Page({ params }: DocsPageProps) { const { slug } = await params; - if (!slug || slug.length === 0) { - redirect("/docs/get-started"); - } - const page = source.getPage(slug ?? []) as SourcePage | undefined; if (!page) notFound(); @@ -45,38 +32,21 @@ export default async function Page({ params }: DocsPageProps) { }} toc={page.data.toc} full={page.data.full} - tableOfContent={{ - footer: , - style: "clerk", - }} - tableOfContentPopover={{ - style: "clerk", - }} > - {page.data.title} +
+ {page.data.title} +
+ +
+
{page.data.description} -
+
- ) => ( -

{children}

- ), - Callout, - Card, - Cards, - Step, - Steps, - Tab, - Tabs, - Features, - PackageInstall, - PackageRun, - }} - /> + + +
); @@ -89,13 +59,6 @@ export function generateStaticParams() { export async function generateMetadata({ params }: DocsPageProps): Promise { const { slug } = await params; - if (!slug || slug.length === 0) { - return { - title: "Documentation", - description: "PayKit documentation", - }; - } - const page = source.getPage(slug ?? []); if (!page) notFound(); @@ -103,22 +66,5 @@ export async function generateMetadata({ params }: DocsPageProps): Promise - {icon ? {icon} : null} - {name} - {badge !== undefined ? {badge} : null} - - ); -} - -function withCategoryFolderDefaults(node: PageTree.Node): PageTree.Node { - if (node.type !== "folder" || node.collapsible !== undefined) { - return node; - } - - return { - ...node, - collapsible: false, - defaultOpen: true, - }; -} - -function groupCategories(nodes: PageTree.Node[]): PageTree.Node[] { - const grouped: PageTree.Node[] = []; - let currentCategory: PageTree.Folder | null = null; - - for (const node of nodes) { - if (node.type === "separator" && node.name) { - currentCategory = { - type: "folder", - name: node.name, - collapsible: true, - defaultOpen: false, - children: [], - } as PageTree.Folder; - - const icon = typeof node.name === "string" ? getDocsCategoryIcon(node.name) : undefined; - ( - currentCategory as PageTree.Folder & { - icon?: ReactNode; - } - ).icon = ; - - grouped.push(currentCategory); - continue; - } - - let mappedNode = - node.type === "folder" - ? { - ...node, - children: groupCategories(node.children), - } - : node; - - if (mappedNode.type === "page") { - const nameStr = typeof mappedNode.name === "string" ? mappedNode.name : ""; - const icon = - nameStr && getDocsPageIcon(nameStr) - ? cloneElement(getDocsPageIcon(nameStr) as ReactElement, { - key: `icon-${nameStr}`, - }) - : undefined; - - if ( - nameStr && - ((isProviderPage(nameStr) && !isEnabledProviderPage(nameStr)) || isSoonPage(nameStr)) - ) { - mappedNode = { - ...mappedNode, - name: withPageLabel( - nameStr, - icon, - - SOON - , - ), - url: "#", - }; - } else if (icon) { - mappedNode = { - ...mappedNode, - name: withPageLabel(nameStr, icon), - }; - } - } - - if ( - mappedNode.type === "folder" && - currentCategory && - normalizeName(String(mappedNode.name)) === normalizeName(String(currentCategory.name)) - ) { - for (const child of mappedNode.children) { - currentCategory.children.push(withCategoryFolderDefaults(child)); - } - continue; - } - - if (currentCategory) { - currentCategory.children.push(withCategoryFolderDefaults(mappedNode)); - continue; - } - - grouped.push(mappedNode); - } - - return grouped; -} - -function withCollapsibleCategories(tree: PageTree.Root): PageTree.Root { - return { - ...tree, - children: groupCategories(tree.children), - }; -} - export default function Layout({ children }: { children: ReactNode }) { - const tree = withCollapsibleCategories(source.pageTree); - - return ( - - - -
- ), - }} - nav={{ - children: , - title: ( -
- - {VERSION_TEXT && ( - - {VERSION_TEXT} - - )} -
- ), - url: "/", - }} - > - {children} - - ); + return {children}; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 910feda3..4e727028 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -66,6 +66,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/apps/web/src/app/llms.txt/route.ts b/apps/web/src/app/llms.txt/route.ts index f5c55db8..eb1a4a5d 100644 --- a/apps/web/src/app/llms.txt/route.ts +++ b/apps/web/src/app/llms.txt/route.ts @@ -8,7 +8,7 @@ const suffix = ` ## AI Access -- Append \`.mdx\` to any documentation page URL to get raw Markdown content (e.g. \`/docs/get-started/installation.mdx\`) +- Append \`.mdx\` to any documentation page URL to get raw Markdown content (e.g. \`/docs/introduction.mdx\`) - Full documentation as a single file: \`/llms-full.txt\` `; diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index bfc052d1..c7687be7 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,7 +1,7 @@ "use client"; -import { ArrowLeft } from "lucide-react"; import Link from "next/link"; +import { RiArrowLeftLine } from "react-icons/ri"; import { MiniNavBar } from "@/components/layout/mini-nav-bar"; import { PageTransition } from "@/components/layout/page-transition"; @@ -9,6 +9,10 @@ import { Section, SectionContent } from "@/components/layout/section"; import { Providers } from "@/components/providers"; import { Button } from "@/components/ui/button"; +/** 404 page shown when a route is missing. + * + * @returns JSX.Element + */ export default function NotFound() { return ( @@ -50,7 +54,7 @@ export default function NotFound() {
diff --git a/apps/web/src/components/command-menu.tsx b/apps/web/src/components/command-menu.tsx deleted file mode 100644 index 890adfa0..00000000 --- a/apps/web/src/components/command-menu.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; - -import { AnimatePresence, motion } from "framer-motion"; -import { useDocsSearch } from "fumadocs-core/search/client"; -import { FileText, Hash, Search, Text } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { cn } from "@/lib/utils"; - -// ─── Context ───────────────────────────────────────────────────────────────── - -type CommandMenuContextValue = { - open: boolean; - setOpen: (open: boolean) => void; -}; - -const CommandMenuContext = createContext(null); - -export function useCommandMenu() { - const ctx = use(CommandMenuContext); - if (!ctx) throw new Error("useCommandMenu must be used within CommandMenuProvider"); - return ctx; -} - -// ─── Provider ──────────────────────────────────────────────────────────────── - -export function CommandMenuProvider({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false); - - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((prev) => !prev); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - const value = useMemo(() => ({ open, setOpen }), [open]); - - return ( - - {children} - - - ); -} - -// ─── Dialog ────────────────────────────────────────────────────────────────── - -function CommandMenuDialog() { - const { open, setOpen } = useCommandMenu(); - const [searchQuery, setSearchQuery] = useState(""); - - const handleOpenChange = useCallback( - (next: boolean) => { - setOpen(next); - if (!next) { - setTimeout(() => { - setSearchQuery(""); - }, 200); - } - }, - [setOpen], - ); - - return ( - - {open && ( - <> - {/* Overlay */} - handleOpenChange(false)} - onKeyDown={(e) => { - if (e.key === "Escape") handleOpenChange(false); - }} - /> - - {/* Dialog */} - { - if (e.key === "Escape") { - e.stopPropagation(); - handleOpenChange(false); - } - }} - > -
- handleOpenChange(false)} - /> -
-
- - )} -
- ); -} - -// ─── Search Mode ───────────────────────────────────────────────────────────── - -function SearchMode({ - query, - setQuery, - onClose, -}: { - query: string; - setQuery: (q: string) => void; - onClose: () => void; -}) { - const { - search: _search, - setSearch, - query: results, - } = useDocsSearch({ - type: "fetch", - api: "/api/search", - }); - const router = useRouter(); - const inputRef = useRef(null); - const [selectedIndex, setSelectedIndex] = useState(0); - - // Sync external query with search - useEffect(() => { - setSearch(query); - }, [query, setSearch]); - - // Auto-focus - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const items = results.data !== "empty" ? (results.data ?? []) : []; - - // Reset selection when results change - useEffect(() => { - setSelectedIndex(0); - }, [items.length]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - } else if (e.key === "Enter" && items[selectedIndex]) { - e.preventDefault(); - router.push(items[selectedIndex].url); - onClose(); - } - }; - - return ( - <> - {/* Input */} -
- - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search documentation..." - className="placeholder:text-muted-foreground flex h-11 w-full bg-transparent py-3 font-mono text-sm outline-none" - /> -
- - {/* Results */} -
- {results.isLoading && ( -
Searching...
- )} - {!results.isLoading && query && items.length === 0 && ( -
No results found.
- )} - {!results.isLoading && !query && ( -
- Type to search documentation... -
- )} - {items.map((item, index) => { - const isNested = item.type === "heading" || item.type === "text"; - const pageName = (item as any).pageName as string | undefined; - return ( - - ); - })} -
- - {/* Footer */} -
-
- - ↑↓ navigate - - - open - - - esc close - -
-
- - ); -} diff --git a/apps/web/src/components/docs/copy-markdown-button.tsx b/apps/web/src/components/docs/copy-markdown-button.tsx index 5b960b40..e784cfd2 100644 --- a/apps/web/src/components/docs/copy-markdown-button.tsx +++ b/apps/web/src/components/docs/copy-markdown-button.tsx @@ -1,34 +1,96 @@ "use client"; -import { Check, Copy } from "lucide-react"; -import { useCallback, useState } from "react"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { + RiArrowDownSLine, + RiCheckLine, + RiExternalLinkLine, + RiFileCopyLine, + RiMarkdownLine, +} from "react-icons/ri"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { const [copied, setCopied] = useState(false); + const [markdown, setMarkdown] = useState(); + + useEffect(() => { + setMarkdown(undefined); + }, [markdownUrl]); const onClick = useCallback(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); - void fetch(markdownUrl) - .then((res) => { - if (!res.ok) throw new Error("fetch failed"); - return res.text(); + void Promise.resolve(markdown) + .then((cached) => { + if (cached !== undefined) return cached; + + return fetch(markdownUrl).then((res) => { + if (!res.ok) throw new Error("fetch failed"); + return res.text(); + }); + }) + .then((text) => { + setMarkdown(text); + return navigator.clipboard.writeText(text); }) - .then((text) => navigator.clipboard.writeText(text)) .catch(() => { toast.error("Failed to copy markdown"); setCopied(false); }); - }, [markdownUrl]); + }, [markdown, markdownUrl]); return ( - +
+ + + + } + > + + + + }> + + View as markdown + + }> + + View llms.txt + + } + > + + View llms-full.txt + + + +
); } diff --git a/apps/web/src/components/docs/docs-code-surface.tsx b/apps/web/src/components/docs/docs-code-surface.tsx new file mode 100644 index 00000000..8e7e4826 --- /dev/null +++ b/apps/web/src/components/docs/docs-code-surface.tsx @@ -0,0 +1,23 @@ +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Renders the styled `
` wrapper used by docs code blocks.
+ *
+ * @param props - Standard pre props, including `className` and `tabIndex`.
+ * Remaining props are spread onto the `
` element. Defaults `tabIndex` to
+ * `0` when undefined so code blocks are keyboard focusable.
+ */
+export function DocsCodeSurface({ className, tabIndex, ...props }: ComponentProps<"pre">) {
+  return (
+    
code]:flex [&>code]:w-max [&>code]:min-w-full [&>code]:flex-col [&>code]:px-0! [&_.line]:px-3",
+      )}
+    />
+  );
+}
diff --git a/apps/web/src/components/docs/docs-icons.tsx b/apps/web/src/components/docs/docs-icons.tsx
index 56ec33b8..189da22e 100644
--- a/apps/web/src/components/docs/docs-icons.tsx
+++ b/apps/web/src/components/docs/docs-icons.tsx
@@ -1,86 +1,83 @@
-import {
-  Blocks,
-  BookMarked,
-  BookOpen,
-  Bot,
-  ChevronDown,
-  Code2,
-  Coins,
-  Compass,
-  CreditCard,
-  Database,
-  Download,
-  Gauge,
-  GitCompareArrows,
-  LayoutDashboard,
-  Monitor,
-  Package,
-  ReceiptText,
-  Repeat,
-  Route,
-  Rocket,
-  Server,
-  Shield,
-  ShoppingCart,
-  Terminal,
-  Users,
-  WalletCards,
-  Webhook,
-  BookText,
-} from "lucide-react";
 import type { ReactElement } from "react";
-
-import { CreemIcon } from "@/components/icons/creem";
+import {
+  RiArrowDownSLine,
+  RiBankCardLine,
+  RiBookMarkedLine,
+  RiBookOpenLine,
+  RiBookReadLine,
+  RiBox3Line,
+  RiCodeSSlashLine,
+  RiCoinsLine,
+  RiCompassLine,
+  RiComputerLine,
+  RiDashboardLine,
+  RiDatabase2Line,
+  RiDownloadLine,
+  RiGitForkLine,
+  RiGroupLine,
+  RiPuzzle2Line,
+  RiReceiptLine,
+  RiRepeatLine,
+  RiRobot2Line,
+  RiRocketLine,
+  RiRouteLine,
+  RiServerLine,
+  RiShieldLine,
+  RiShoppingCartLine,
+  RiSpeedUpLine,
+  RiTerminalBoxLine,
+  RiWalletLine,
+  RiWebhookLine,
+} from "react-icons/ri";
 
 const categoryIcons = {
-  "get started": ,
-  concepts: ,
-  flows: ,
-  providers: ,
-  databases: ,
-  integrations: ,
-  plugins: ,
-  guides: ,
+  "get started": ,
+  concepts: ,
+  flows: ,
+  providers: ,
+  databases: ,
+  integrations: ,
+  plugins: ,
+  guides: ,
 } as const;
 
 const pageIcons = {
-  introduction: ,
-  comparison: ,
-  installation: ,
-  quickstart: ,
-  "server api": ,
-  "react client": ,
-  "webhook events": ,
-  "basic usage": ,
-  usage: ,
-  database: ,
-  typescript: ,
-  "payment providers": ,
-  checkout: ,
-  "payment methods": ,
-  charges: ,
-  postgres: ,
-  sqlite: ,
-  "drizzle adapter": ,
-  "prisma adapter": ,
-  nextjs: ,
-  "next js": ,
+  introduction: ,
+  comparison: ,
+  installation: ,
+  quickstart: ,
+  "server api": ,
+  "react client": ,
+  "webhook events": ,
+  "basic usage": ,
+  usage: ,
+  database: ,
+  typescript: ,
+  checkout: ,
+  "payment methods": ,
+  charges: ,
+  postgres: ,
+  sqlite: ,
+  "drizzle adapter": ,
+  "prisma adapter": ,
+  nextjs: ,
+  "next js": ,
 
-  "create a payment provider": ,
-  "plans & features": ,
-  customers: ,
-  subscriptions: ,
-  entitlements: ,
-  plugins: ,
-  client: ,
-  cli: ,
-  "subscription billing": ,
-  "metered usage": ,
-  dashboard: ,
-  skills: ,
+  "create a payment provider": ,
+  "plans & features": ,
+  customers: ,
+  subscriptions: ,
+  entitlements: ,
+  plugins: ,
+  client: ,
+  cli: ,
+  "subscription billing": ,
+  "metered usage": ,
+  dashboard: ,
+  skills: ,
 } as const;
 
-const enabledProviders = new Set(["stripe", "polar"]);
+const enabledProviders = new Set(["stripe"]);
 const soonPages = new Set(["drizzleadapter", "prismaadapter", "dashboard"]);
 
 const providerPageIcons = {
@@ -98,100 +95,6 @@ const providerPageIcons = {
       />
     
   ),
-  paypal: (
-    
-      
-        
-        
-        
-      
-    
-  ),
-  polar: (
-    
-      
-      
-      
-      
-    
-  ),
-  lemonsqueezy: (
-    
-      
-    
-  ),
-  paddle: (
-    
-      
-    
-  ),
-  creem: ,
 } as const;
 
 function normalizeCategoryName(name: string): string {
@@ -238,7 +141,7 @@ export function CategoryFolderIcon({ icon }: { icon?: ReactElement }) {
   return (
     
       {icon}
-      
+      
     
   );
 }
diff --git a/apps/web/src/components/docs/docs-layout.tsx b/apps/web/src/components/docs/docs-layout.tsx
new file mode 100644
index 00000000..f768fea2
--- /dev/null
+++ b/apps/web/src/components/docs/docs-layout.tsx
@@ -0,0 +1,491 @@
+"use client";
+
+import { useHotkey } from "@tanstack/react-hotkeys";
+import type { Root } from "fumadocs-core/page-tree";
+import type * as PageTree from "fumadocs-core/page-tree";
+import { useSearchContext } from "fumadocs-ui/contexts/search";
+import { TreeContextProvider } from "fumadocs-ui/contexts/tree";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { CSSProperties } from "react";
+import type { ReactNode } from "react";
+import { useEffect } from "react";
+import { useState } from "react";
+import { RiExternalLinkLine, RiRobot2Line, RiSearchLine, RiSideBarLine } from "react-icons/ri";
+
+import { getDocsPageIcon } from "@/components/docs/docs-icons";
+import { ThemeSwitcher } from "@/components/theme-switcher";
+import { Button } from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from "@/components/ui/sheet";
+import { BrandMenu } from "@/components/web/brand-menu";
+import { cn } from "@/lib/utils";
+
+type DocsLayoutStyle = CSSProperties & {
+  "--fd-layout-width": string;
+  "--fd-sidebar-col": string;
+};
+
+function SearchButton({ className }: { className?: string }) {
+  const { setOpenSearch } = useSearchContext();
+
+  return (
+    
+  );
+}
+
+function SidebarContent({
+  onItemClick,
+  pathname,
+  tree,
+}: {
+  onItemClick?: () => void;
+  pathname: string;
+  tree: Root;
+}) {
+  const sections = getSidebarSections(tree.children);
+
+  return (
+    
+  );
+}
+
+function LlmsDropdown() {
+  return (
+    
+      
+        }
+      >
+        
+      
+      
+        }>
+          
+          llms.txt
+        
+        }>
+          
+          llms-full.txt
+        
+      
+    
+  );
+}
+
+function DocsSidebar({
+  onCollapse,
+  open,
+  tree,
+}: {
+  onCollapse: () => void;
+  open: boolean;
+  tree: Root;
+}) {
+  const pathname = usePathname();
+
+  return (
+    
+ +
+ ); +} + +function SidebarIsland({ + onOpen, + onSearch, + visible, +}: { + onOpen: () => void; + onSearch: () => void; + visible: boolean; +}) { + return ( + + ); +} + +function MobileSidebar({ + onOpenChange, + open, + tree, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; + tree: Root; +}) { + const pathname = usePathname(); + + return ( + + + + Documentation navigation + Browse docs pages and sections. + +
+
+ +
+ onOpenChange(false)} pathname={pathname} tree={tree} /> +
+
+
+ ); +} + +interface SidebarSection { + key?: string; + separator?: PageTree.Separator; + children: PageTree.Node[]; +} + +function getSidebarSections(nodes: PageTree.Node[]): SidebarSection[] { + const sections: SidebarSection[] = []; + let current: SidebarSection | undefined; + + for (const [index, node] of nodes.entries()) { + const next = nodes[index + 1]; + + if (node.type === "separator") { + if (isDuplicateFolderSeparator(node, next)) { + current = undefined; + continue; + } + + current = { + key: node.$id, + separator: node, + children: [], + }; + sections.push(current); + continue; + } + + if (!current) { + current = { + key: node.$id, + children: [], + }; + sections.push(current); + } + + current.children.push(node); + } + + return sections.filter((section) => section.separator || section.children.length > 0); +} + +function isDuplicateFolderSeparator( + separator: PageTree.Separator, + next: PageTree.Node | undefined, +): boolean { + return ( + next?.type === "folder" && + String(separator.name).toLowerCase() === String(next.name).toLowerCase() + ); +} + +function SidebarSeparator({ separator }: { separator: PageTree.Separator }) { + return ( +
+ {separator.name} +
+ ); +} + +function SidebarNode({ + node, + onItemClick, + pathname, +}: { + node: PageTree.Node; + onItemClick?: () => void; + pathname: string; +}) { + if (node.type === "separator") { + return ; + } + + if (node.type === "folder") { + return ; + } + + return ; +} + +function SidebarFolder({ + folder, + onItemClick, + pathname, +}: { + folder: PageTree.Folder; + onItemClick?: () => void; + pathname: string; +}) { + return ( +
+
+ {folder.name} +
+
+ {folder.index ? ( + + ) : null} + {folder.children.map((node, index) => ( + + ))} +
+
+ ); +} + +function SidebarItem({ + item, + onItemClick, + pathname, +}: { + item: PageTree.Item; + onItemClick?: () => void; + pathname: string; +}) { + const active = pathname === item.url; + + return ( + + {getDocsPageIcon(String(item.name))} + {item.name} + + ); +} + +export function DocsLayout({ children, tree }: { children: ReactNode; tree: Root }) { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [previousSidebarOpen, setPreviousSidebarOpen] = useState(sidebarOpen); + const { setOpenSearch } = useSearchContext(); + const isColumnChanged = previousSidebarOpen !== sidebarOpen; + + useHotkey( + "Mod+B", + () => { + setSidebarOpen((open) => !open); + }, + { + ignoreInputs: true, + preventDefault: true, + }, + ); + + useEffect(() => { + if (isColumnChanged) setPreviousSidebarOpen(sidebarOpen); + }, [isColumnChanged, sidebarOpen]); + + const layoutStyle = { + "--fd-layout-width": "90rem", + "--fd-sidebar-col": sidebarOpen ? "var(--fd-sidebar-width)" : "0px", + gridTemplateAreas: ` + "sidebar sidebar header toc toc" + "sidebar sidebar toc-popover toc toc" + "sidebar sidebar main toc toc" + `, + gridTemplateRows: "var(--fd-header-height) var(--fd-toc-popover-height) 1fr", + gridTemplateColumns: + "minmax(0, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(0, 1fr)", + } satisfies DocsLayoutStyle; + + return ( + +
+ setSidebarOpen(false)} open={sidebarOpen} tree={tree} /> + setSidebarOpen(true)} + onSearch={() => setOpenSearch(true)} + visible={!sidebarOpen} + /> + +
+ +
+ + +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/docs/docs-mdx-components.tsx b/apps/web/src/components/docs/docs-mdx-components.tsx new file mode 100644 index 00000000..64831709 --- /dev/null +++ b/apps/web/src/components/docs/docs-mdx-components.tsx @@ -0,0 +1,312 @@ +import type { MDXComponents } from "mdx/types"; +import Link from "next/link"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { + RiCheckboxCircleFill, + RiCloseCircleFill, + RiErrorWarningFill, + RiInformationFill, + RiLinkM, +} from "react-icons/ri"; + +import { Features } from "@/components/docs/features"; +import { MdxTab, MdxTabs, MdxTabsList, MdxTabsPanel, MdxTabsTab } from "@/components/docs/mdx-tabs"; +import { + Anchor, + InlineCode, + MDXLink, + Step, + StepContent, + StepDescription, + Steps, + StepTitle, +} from "@/components/docs/mdx-text"; +import { PackageCommandPre } from "@/components/docs/package-command-pre"; +import { extractText } from "@/components/docs/react-node-text"; +import { cn } from "@/lib/utils"; + +type DocsCalloutType = "info" | "warn" | "error" | "success"; + +const calloutStyles = { + info: { + icon: RiInformationFill, + tone: "text-blue-500", + }, + warn: { + icon: RiErrorWarningFill, + tone: "text-amber-500", + }, + error: { + icon: RiCloseCircleFill, + tone: "text-red-500", + }, + success: { + icon: RiCheckboxCircleFill, + tone: "text-emerald-500", + }, +} satisfies Record; + +function Paragraph({ className, ...props }: ComponentPropsWithoutRef<"p">) { + return

; +} + +function Strong({ className, ...props }: ComponentPropsWithoutRef<"strong">) { + return ; +} + +function getHeadingId(children: ComponentPropsWithoutRef<"h2">["children"]) { + const text = extractText(children); + + return ( + text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-") || "heading" + ); +} + +function Heading1({ className, children, ...props }: ComponentPropsWithoutRef<"h1">) { + return ( +

+ {children} +

+ ); +} + +function Heading2({ className, ...props }: ComponentPropsWithoutRef<"h2">) { + // Preserve Fumadocs-generated IDs so TOC active tracking stays in sync. + const headingId = typeof props.id === "string" ? props.id : getHeadingId(props.children); + + return ( + + +

+ + ); +} + +function Heading3({ className, ...props }: ComponentPropsWithoutRef<"h3">) { + return ( +

+ ); +} + +function Heading4({ className, ...props }: ComponentPropsWithoutRef<"h4">) { + return ( +

+ ); +} + +function Heading5({ className, ...props }: ComponentPropsWithoutRef<"h5">) { + return ( +

+ ); +} + +function Heading6({ className, ...props }: ComponentPropsWithoutRef<"h6">) { + return ( +
+ ); +} + +function UnorderedList({ className, ...props }: ComponentPropsWithoutRef<"ul">) { + return
    ; +} + +function OrderedList({ className, ...props }: ComponentPropsWithoutRef<"ol">) { + return
      ; +} + +function ListItem({ className, ...props }: ComponentPropsWithoutRef<"li">) { + return
    1. ; +} + +function Blockquote({ className, ...props }: ComponentPropsWithoutRef<"blockquote">) { + return
      ; +} + +function HorizontalRule({ className, ...props }: ComponentPropsWithoutRef<"hr">) { + return
      ; +} + +function Table({ className, ...props }: ComponentPropsWithoutRef<"table">) { + return ( +
      + + + ); +} + +function TableRow({ className, ...props }: ComponentPropsWithoutRef<"tr">) { + return ; +} + +function TableHead({ className, ...props }: ComponentPropsWithoutRef<"th">) { + return ( +
      + ); +} + +function TableCell({ className, ...props }: ComponentPropsWithoutRef<"td">) { + return ( + + ); +} + +function Code({ children, ...props }: ComponentPropsWithoutRef<"code">) { + if (typeof children === "string") { + return {children}; + } + + return {children}; +} + +function Callout({ + className, + type = "info", + children, + ...props +}: ComponentPropsWithoutRef<"div"> & { type?: DocsCalloutType }) { + const { icon: Icon, tone } = calloutStyles[type]; + + return ( +
      +
      + +
      {children}
      +
      + ); +} + +function Cards({ className, ...props }: ComponentPropsWithoutRef<"div">) { + return
      ; +} + +function Card({ + className, + href, + children, + ...props +}: ComponentPropsWithoutRef<"div"> & { href?: string; children?: ReactNode }) { + const content = ( +
      + {children} +
      + ); + + if (!href) return content; + + return ( + + {content} + + ); +} + +/** MDX component overrides used by documentation pages. Maps standard MDX + * elements to styled React components and exposes docs-only components such as + * Callout, Card, Tabs, Steps, and Features. Public API for MDX rendering. + */ +export const docsMdxComponents = { + pre: PackageCommandPre, + h1: Heading1, + h2: Heading2, + h3: Heading3, + h4: Heading4, + h5: Heading5, + h6: Heading6, + p: Paragraph, + strong: Strong, + a: Anchor, + code: Code, + ul: UnorderedList, + ol: OrderedList, + li: ListItem, + blockquote: Blockquote, + hr: HorizontalRule, + table: Table, + tr: TableRow, + th: TableHead, + td: TableCell, + Callout, + Card, + Cards, + Link: MDXLink, + Step, + StepContent, + StepDescription, + Steps, + StepTitle, + Tab: MdxTab, + Tabs: MdxTabs, + TabsList: MdxTabsList, + TabsPanel: MdxTabsPanel, + TabsTab: MdxTabsTab, + Features, +} satisfies MDXComponents; diff --git a/apps/web/src/components/docs/docs-page.tsx b/apps/web/src/components/docs/docs-page.tsx new file mode 100644 index 00000000..e9d5256a --- /dev/null +++ b/apps/web/src/components/docs/docs-page.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { getBreadcrumbItemsFromPath, type BreadcrumbOptions } from "fumadocs-core/breadcrumb"; +import { usePathname } from "fumadocs-core/framework"; +import Link from "fumadocs-core/link"; +import type * as PageTree from "fumadocs-core/page-tree"; +import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; +import { TOC, TOCProvider, type TOCProviderProps } from "fumadocs-ui/layouts/docs/page/slots/toc"; +import type { ComponentProps, ReactNode } from "react"; +import { Fragment, useMemo } from "react"; +import { RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri"; + +import { cn } from "@/lib/utils"; + +const tocWidthClassName = "xl:layout:[--fd-toc-width:250px]"; + +export function DocsPage({ + children, + className, + breadcrumb, + footer = true, + full = false, + toc = [], + tocFooter, +}: ComponentProps<"article"> & { + breadcrumb?: BreadcrumbOptions & { enabled?: boolean }; + footer?: boolean; + full?: boolean; + toc?: TOCProviderProps["toc"]; + tocFooter?: ReactNode; +}) { + const hasToc = toc.length > 0 || tocFooter !== undefined; + + return ( + + {hasToc && } +
      + {breadcrumb?.enabled !== false && } + {children} + {footer && } +
      + {hasToc && } +
      + ); +} + +export function DocsTitle({ children, className, ...props }: ComponentProps<"h1">) { + return ( +

      + {children} +

      + ); +} + +export function DocsDescription({ children, className, ...props }: ComponentProps<"p">) { + if (children === undefined) return null; + + return ( +

      + {children} +

      + ); +} + +export function DocsBody({ children, className, ...props }: ComponentProps<"div">) { + return ( +
      + {children} +
      + ); +} + +function DocsToc({ footer }: { footer?: ReactNode }) { + return ; +} + +function DocsTocLayoutMarker() { + return
      ; +} + +function DocsBreadcrumb({ + includePage, + includeRoot, + includeSeparator, + className, + ...props +}: BreadcrumbOptions & ComponentProps<"div">) { + const path = useTreePath(); + const { root } = useTreeContext(); + const items = useMemo( + () => + getBreadcrumbItemsFromPath(root, path, { + includePage, + includeRoot, + includeSeparator, + }), + [includePage, includeRoot, includeSeparator, path, root], + ); + + if (items.length === 0) return null; + + return ( +
      + {items.map((item, index) => { + const itemClassName = cn( + "truncate", + index === items.length - 1 && "font-medium text-primary", + ); + + return ( + + {index !== 0 && } + {item.url ? ( + + {item.name} + + ) : ( + {item.name} + )} + + ); + })} +
      + ); +} + +function DocsFooter({ className, ...props }: ComponentProps<"div">) { + const pathname = usePathname(); + const { root } = useTreeContext(); + const footerList = useMemo(() => flattenFooterItems(root.children), [root.children]); + const index = footerList.findIndex((item) => item.url === pathname); + const previous = index > 0 ? footerList[index - 1] : undefined; + const next = index >= 0 ? footerList[index + 1] : undefined; + + if (!previous && !next) return null; + + return ( +
      + {previous && } + {next && } +
      + ); +} + +function DocsFooterItem({ item, index }: { item: FooterItem; index: 0 | 1 }) { + const Icon = index === 0 ? RiArrowLeftSLine : RiArrowRightSLine; + + return ( + +
      + +

      {item.name}

      +
      +

      + {item.description ?? (index === 0 ? "Previous page" : "Next page")} +

      + + ); +} + +type FooterItem = Pick; + +function flattenFooterItems(nodes: PageTree.Node[]): FooterItem[] { + const items: FooterItem[] = []; + + for (const node of nodes) { + if (node.type === "page" && node.url !== "#") { + items.push({ + description: node.description, + name: node.name, + url: node.url, + }); + continue; + } + + if (node.type === "folder") { + items.push(...flattenFooterItems(node.children)); + } + } + + return items; +} diff --git a/apps/web/src/components/docs/features.tsx b/apps/web/src/components/docs/features.tsx index d34a9e44..21e4ab10 100644 --- a/apps/web/src/components/docs/features.tsx +++ b/apps/web/src/components/docs/features.tsx @@ -1,61 +1,63 @@ "use client"; -import { - Blocks, - Cable, - Database, - Gauge, - Monitor, - Package, - ShieldCheck, - Terminal, - Webhook, -} from "lucide-react"; import type { ReactNode } from "react"; +import { + RiBox3Line, + RiComputerLine, + RiDatabase2Line, + RiPlug2Line, + RiPuzzle2Line, + RiShieldCheckLine, + RiSpeedUpLine, + RiTerminalBoxLine, + RiWebhookLine, +} from "react-icons/ri"; + +import { FrameCorners } from "@/components/ui/frame-corners"; const features: { icon: ReactNode; title: string; description: string }[] = [ { - icon: , + icon: , title: "Products in Code", description: "Define plans and features as typed primitives.", }, { - icon: , + icon: , title: "Webhooks Handled", description: "Verified, deduplicated, synced to your database automatically.", }, { - icon: , + icon: , title: "Usage Billing", description: "Metered features with check() and report().", }, { - icon: , - title: "Any Provider", - description: "Stripe, Polar, Creem, or your own. Swap with one import.", + icon: , + title: "Built For Stripe", + description: "Stripe subscriptions, webhooks, portal, and product sync built in.", }, { - icon: , + icon: , title: "Plugin Ecosystem", description: "Dashboard, analytics, or build your own plugin.", }, { - icon: , + icon: , title: "Local Billing State", description: "Billing state in your Postgres, joinable with your tables.", }, { - icon: , + icon: , title: "CLI", description: "Init, push, and status. Scaffold, migrate, validate.", }, { - icon: , + icon: , title: "Client SDK", description: "Browser-side billing calls with full type inference.", }, { - icon: , + icon: , title: "Type-safe", description: "Plan IDs, feature IDs, events — all inferred from your schema.", }, @@ -63,22 +65,19 @@ const features: { icon: ReactNode; title: string; description: string }[] = [ export function Features() { return ( -
      +
      {features.map((feature) => (
      -
      - - {feature.icon} - -
      -

      {feature.title}

      -

      - {feature.description} -

      -
      + + + {feature.icon} + +
      +

      {feature.title}

      +

      {feature.description}

      ))} diff --git a/apps/web/src/components/docs/mdx-tabs.tsx b/apps/web/src/components/docs/mdx-tabs.tsx new file mode 100644 index 00000000..94d5a710 --- /dev/null +++ b/apps/web/src/components/docs/mdx-tabs.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { + createContext, + useContext, + useMemo, + useState, + type ComponentProps, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +interface MdxTabsContextValue { + items?: string[]; +} + +const MdxTabsContext = createContext(null); + +function getDefaultValue(items?: string[], defaultIndex = 0, defaultValue?: string) { + return defaultValue ?? items?.[defaultIndex]; +} + +export interface MdxTabsProps extends Omit { + items?: string[]; + defaultIndex?: number; + defaultValue?: string; + label?: ReactNode; +} + +/** Root tabs component for MDX content. + * + * Supports controlled selection via `items`, `defaultIndex`, `defaultValue`, and optional `label`. + */ +export function MdxTabs({ + className, + items, + defaultIndex = 0, + defaultValue, + label, + children, + ...props +}: MdxTabsProps) { + const [value, setValue] = useState(() => getDefaultValue(items, defaultIndex, defaultValue)); + const context = useMemo(() => ({ items }), [items]); + + return ( + { + if (items && !items.includes(nextValue)) { + if (process.env.NODE_ENV !== "production") { + console.warn( + `Ignoring unknown MDX tab value "${nextValue}". Expected one of: ${items.join(", ")}.`, + ); + } + return; + } + setValue(nextValue); + }} + {...props} + > + {items ? ( + + {label ? {label} : null} + {items.map((item) => ( + + {item} + + ))} + + ) : null} + {children} + + ); +} + +/** Tab list container with the active-tab indicator. */ +export function MdxTabsList({ + indicatorClassName, + className, + children, + ...props +}: TabsPrimitive.List.Props & { + indicatorClassName?: string; +}) { + return ( + + {children} + + + ); +} + +/** Individual MDX tab trigger. + * + * Pass `value` to connect the trigger to a matching panel. + */ +export function MdxTabsTab({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ); +} + +/** Content panel for an MDX tab value. */ +export function MdxTabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + .docs-codeblock:first-child]:mt-0 [&>p:first-child]:mt-[3px] [&_h3]:text-base [&_h3]:font-medium [&>.steps]:mt-6", + className, + )} + data-slot="tabs-content" + {...props} + /> + ); +} + +/** Convenience tab panel that resolves `value` from props or tab context. + * + * @throws If no tab value can be resolved. + */ +export function MdxTab({ value, ...props }: ComponentProps) { + const context = useContext(MdxTabsContext); + const resolvedValue = value ?? context?.items?.[0]; + + if (!resolvedValue) { + throw new Error("Failed to resolve tab value. Pass a value prop to ."); + } + + return ; +} diff --git a/apps/web/src/components/docs/mdx-text.tsx b/apps/web/src/components/docs/mdx-text.tsx new file mode 100644 index 00000000..10f600a1 --- /dev/null +++ b/apps/web/src/components/docs/mdx-text.tsx @@ -0,0 +1,149 @@ +"use client"; + +import Link from "next/link"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { Children, cloneElement, isValidElement } from "react"; +import { RiLinkM } from "react-icons/ri"; + +import { cn } from "@/lib/utils"; + +const linkDecorationClassName = "decoration-primary/50 group-hover:decoration-primary"; +const linkIconClassName = + "group-hover:text-primary text-muted-foreground mb-0.5 ml-px inline size-3 duration-100"; + +export function Anchor({ className, ...props }: ComponentProps<"a">) { + return ( + + + {props.children} + + + + ); +} + +export function MDXLink({ + children, + className, + href, + _blank, +}: { + _blank?: boolean; + children: ReactNode; + className?: string; + href: string; +}) { + return ( + + + {children} + + + + ); +} + +export function InlineCode({ className, ...props }: ComponentProps<"code">) { + return ( + + ); +} + +export function Steps({ className, children, ...props }: HTMLAttributes) { + const steps = Children.toArray(children).filter((child) => isValidElement(child)); + + return ( +
      + {steps.map((child, index) => { + const step = child as React.ReactElement; + const isFirstStep = index === 0; + const isLastStep = index === steps.length - 1; + + return ( +
      + + ); + })} +
      + ); +} + +type StepProps = HTMLAttributes; + +export function Step({ className, children, ...props }: StepProps) { + return ( +
      +
      {children}
      +
      + ); +} + +export function StepTitle({ className, children }: { children: string; className?: string }) { + return ( +

      + {children} +

      + ); +} + +export function StepDescription({ + className, + children, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
      p]:leading-relaxed", + )} + > + {children} +
      + ); +} + +export function StepContent({ children, className, ...props }: HTMLAttributes) { + return ( +
      + {children} +
      + ); +} diff --git a/apps/web/src/components/docs/package-command-pre.tsx b/apps/web/src/components/docs/package-command-pre.tsx new file mode 100644 index 00000000..600a0d2c --- /dev/null +++ b/apps/web/src/components/docs/package-command-pre.tsx @@ -0,0 +1,87 @@ +import { highlight } from "fumadocs-core/highlight"; +import type { ComponentProps, ReactNode } from "react"; + +import { DocsCodeSurface } from "@/components/docs/docs-code-surface"; +import { DefaultPre, PackageManagerCommandBlock } from "@/components/docs/package-command"; +import { commandForManager, type PackageCommand } from "@/components/docs/package-command-utils"; +import { packageManagers, type PackageManager } from "@/components/docs/package-manager-state"; +import { extractText } from "@/components/docs/react-node-text"; +import { shikiHighlightOptions } from "@/lib/shiki-themes"; + +function normalizeCommand(value: string): string { + return value.replace(/\n+$/, "").trim(); +} + +function hasSingleLine(value: string): boolean { + return value.split("\n").filter((line) => line.trim().length > 0).length === 1; +} + +function isShellLanguage(language: unknown): boolean { + if (typeof language !== "string") return false; + return ["bash", "sh", "shell", "zsh"].includes(language); +} + +function parsePackageCommand(command: string): PackageCommand | null { + const trimmed = normalizeCommand(command); + + for (const prefix of ["npm install ", "npm i "]) { + if (trimmed.startsWith(prefix)) return { kind: "install", args: trimmed.slice(prefix.length) }; + } + + if (trimmed.startsWith("npx ")) return { kind: "dlx", args: trimmed.slice("npx ".length) }; + if (trimmed.startsWith("npm create ")) { + return { kind: "create", args: trimmed.slice("npm create ".length) }; + } + if (trimmed.startsWith("npm run ")) { + return { kind: "run", args: trimmed.slice("npm run ".length) }; + } + + return null; +} + +async function highlightShellCommand(command: string) { + return highlight(command, { + lang: "bash", + ...shikiHighlightOptions, + components: { + pre: (props: ComponentProps<"pre">) => , + }, + }); +} + +/** + * Renders docs code fences, upgrading single-line shell package commands. + * + * Normalizes/extracts text, parses supported package-manager commands, checks + * the code fence language, asynchronously highlights each manager variant, and + * renders either `PackageManagerCommandBlock` or `DefaultPre`. + * + * @param props - Pre props plus optional `data-language` from the code fence. + * @returns Highlighted package command block or the default pre element. + */ +export async function PackageCommandPre( + props: ComponentProps<"pre"> & { "data-language"?: string }, +) { + const commandText = normalizeCommand(extractText(props.children)); + const command = hasSingleLine(commandText) ? parsePackageCommand(commandText) : null; + + if (command && isShellLanguage(props["data-language"])) { + const highlightedCommandEntries = await Promise.all( + packageManagers.map(async (manager) => [ + manager, + await highlightShellCommand(commandForManager(command, manager)), + ]), + ); + + return ( + + } + /> + ); + } + + return ; +} diff --git a/apps/web/src/components/docs/package-command-utils.ts b/apps/web/src/components/docs/package-command-utils.ts new file mode 100644 index 00000000..0a55ccb4 --- /dev/null +++ b/apps/web/src/components/docs/package-command-utils.ts @@ -0,0 +1,36 @@ +import type { PackageManager } from "@/components/docs/package-manager-state"; + +type CommandKind = "install" | "dlx" | "create" | "run"; + +/** Parsed package-manager command metadata. */ +export interface PackageCommand { + /** Command category parsed from a shell snippet. */ + kind: CommandKind; + /** Arguments after the package-manager command prefix. */ + args: string; +} + +/** + * Formats a parsed command for the selected package manager. + * + * @param command - Parsed package command. + * @param manager - Target package manager. + * @returns Shell command for npm, yarn, bun, or pnpm. `dlx` defaults to pnpm. + */ +export function commandForManager(command: PackageCommand, manager: PackageManager): string { + switch (command.kind) { + case "install": + return manager === "npm" ? `npm install ${command.args}` : `${manager} add ${command.args}`; + case "dlx": + if (manager === "npm") return `npx ${command.args}`; + if (manager === "yarn") return `yarn dlx ${command.args}`; + if (manager === "bun") return `bunx --bun ${command.args}`; + return `pnpm dlx ${command.args}`; + case "create": + return `${manager} create ${command.args}`; + case "run": + if (manager === "npm") return `npm run ${command.args}`; + if (manager === "bun") return `bun run ${command.args}`; + return `${manager} ${command.args}`; + } +} diff --git a/apps/web/src/components/docs/package-command.tsx b/apps/web/src/components/docs/package-command.tsx index 3c4ad3b7..2a46e535 100644 --- a/apps/web/src/components/docs/package-command.tsx +++ b/apps/web/src/components/docs/package-command.tsx @@ -1,56 +1,423 @@ -import { highlight } from "fumadocs-core/highlight"; -import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock"; -import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +"use client"; -import { shikiHighlightOptions } from "@/lib/shiki-themes"; +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import type { ComponentProps, ReactNode, SVGProps } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useState, +} from "react"; +import { RiCheckLine, RiFileCopyLine } from "react-icons/ri"; -const managers = ["pnpm", "bun", "npm"] as const; +import { DocsCodeSurface } from "@/components/docs/docs-code-surface"; +import { commandForManager, type PackageCommand } from "@/components/docs/package-command-utils"; +import { + fallbackPackageManager, + isPackageManager, + packageManagerStorageKey, + packageManagers, + type PackageManager, +} from "@/components/docs/package-manager-state"; +import { extractText } from "@/components/docs/react-node-text"; +import { buttonVariants } from "@/components/ui/button"; +import { useCopyButton } from "@/components/ui/use-copy-button"; +import { cn } from "@/lib/utils"; -function installCommand(pkg: string, manager: string) { - if (manager === "npm") return `npm install ${pkg}`; - return `${manager} add ${pkg}`; +type TabsVariant = "default" | "underline"; +type PackageManagerListener = (value: PackageManager) => void; + +const packageManagerListeners = new Set(); +const PackageManagerContext = createContext(fallbackPackageManager); + +function readStoredManager(): PackageManager | null { + const storedValue = localStorage.getItem(packageManagerStorageKey); + + return isPackageManager(storedValue) ? storedValue : null; } -function runCommand(command: string, manager: string) { - if (manager === "pnpm") return `pnpm dlx ${command}`; - if (manager === "bun") return `bunx ${command}`; - return `npx ${command}`; +function setStoredManager(value: PackageManager) { + localStorage.setItem(packageManagerStorageKey, value); + + for (const listener of packageManagerListeners) { + listener(value); + } } -async function HighlightedCode({ code }: { code: string }) { - return highlight(code, { - lang: "bash", - ...shikiHighlightOptions, - components: { - pre: (props) => ( - -
      {props.children}
      -
      - ), - }, - }); +function usePackageManager() { + const initialManager = useContext(PackageManagerContext); + const [manager, setManagerState] = useState(initialManager); + + useLayoutEffect(() => { + const storedManager = readStoredManager(); + if (storedManager) setManagerState(storedManager); + + packageManagerListeners.add(setManagerState); + + return () => { + packageManagerListeners.delete(setManagerState); + }; + }, []); + + const setManager = useCallback((value: PackageManager) => { + setManagerState(value); + setStoredManager(value); + }, []); + + return [manager, setManager] as const; } -export async function PackageInstall({ package: pkg }: { package: string }) { +export function PackageManagerProvider({ + initialManager, + children, +}: { + initialManager: PackageManager; + children: ReactNode; +}) { return ( - - {managers.map((m) => ( - - - - ))} - + + {children} + + ); +} + +function Tabs({ className, ...props }: TabsPrimitive.Root.Props) { + return ( + + ); +} + +function TabsList({ + variant = "default", + indicatorClassName, + className, + children, + ...props +}: TabsPrimitive.List.Props & { + variant?: TabsVariant; + indicatorClassName?: string; +}) { + const [enableIndicatorTransition, setEnableIndicatorTransition] = useState(false); + + useEffect(() => { + const timeout = window.setTimeout(() => setEnableIndicatorTransition(true), 80); + + return () => window.clearTimeout(timeout); + }, []); + + return ( + + {children} + + + ); +} + +function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ); +} + +function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ); +} + +function cleanCopyText(value: string): string { + return value + .replace(/\s*\/\/\s*\[!code\s+[^\]]+\]\s*$/gm, "") + .replace(/\s*\{?\s*\/\*\s*\[!code\s+[^\]]+\]\s*\*\/\s*\}?\s*$/gm, "") + .replace(/\s*\s*$/gm, "") + .replace(/\n+$/, ""); +} + +function CopyButton({ className, code }: { className?: string; code: string }) { + const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(code)); + + return ( + ); } -export async function PackageRun({ command }: { command: string }) { +export function PackageManagerCommandBlock({ + command, + highlightedCommands, +}: { + command: PackageCommand; + highlightedCommands: Record; +}) { + const [manager, setManager] = usePackageManager(); + const activeCommand = commandForManager(command, manager); + return ( - - {managers.map((m) => ( - - - - ))} + { + if (isPackageManager(value)) setManager(value); + }} + > +
      +
      + + + + pnpm + + + + npm + + + + bun + + + + yarn + + + +
      + {packageManagers.map((item) => ( + + {highlightedCommands[item]} + + ))} +
      ); } + +export function DefaultPre({ + children, + className, + title, + icon, + ...props +}: ComponentProps<"pre"> & { icon?: ReactNode }) { + const code = cleanCopyText(extractText(children)); + const hasTitle = title !== undefined; + + return ( +
      + {hasTitle ? ( +
      +
      + {typeof icon === "string" ? ( +
      + ) : ( + icon + )} +
      {title}
      +
      + +
      + ) : null} + {!hasTitle ? ( + + ) : null} + + {children} + +
      + ); +} + +function NpmIcon({ + fill = "currentColor", + width = "1em", + height = "1em", + ...props +}: SVGProps) { + return ( + + + + ); +} + +function YarnIcon({ + fill = "currentColor", + width = "1em", + height = "1em", + ...props +}: SVGProps) { + return ( + + + + ); +} + +function BunIcon({ + fill = "currentColor", + width = "1em", + height = "1em", + ...props +}: SVGProps) { + return ( + + + + + + + + + + + ); +} + +function PnpmIcon({ + fill = "currentColor", + width = "1em", + height = "1em", + ...props +}: SVGProps) { + return ( + + + + + ); +} diff --git a/apps/web/src/components/docs/package-manager-state.ts b/apps/web/src/components/docs/package-manager-state.ts new file mode 100644 index 00000000..b902cd0a --- /dev/null +++ b/apps/web/src/components/docs/package-manager-state.ts @@ -0,0 +1,38 @@ +/** Supported package manager identifiers. */ +export type PackageManager = "npm" | "yarn" | "bun" | "pnpm"; + +/** Ordered package managers displayed by command switchers. */ +export const packageManagers = [ + "pnpm", + "npm", + "bun", + "yarn", +] as const satisfies readonly PackageManager[]; + +/** Storage key for the selected package manager preference. */ +export const packageManagerStorageKey = "paykit-package-manager"; + +/** Default package manager used when no valid preference exists. */ +export const fallbackPackageManager: PackageManager = "pnpm"; + +/** + * Checks whether a value is a supported package manager. + * + * @param value - Candidate package manager string. + * @returns Whether `value` is a PackageManager. + */ +export function isPackageManager(value: string | null): value is PackageManager { + return packageManagers.includes(value as PackageManager); +} + +/** + * Parses a package manager value, falling back to pnpm when invalid. + * + * @param value - Candidate package manager string. + * @returns A supported package manager. + */ +export function parsePackageManager(value: string | null | undefined): PackageManager { + const candidate = value ?? null; + if (isPackageManager(candidate)) return candidate; + return fallbackPackageManager; +} diff --git a/apps/web/src/components/docs/react-node-text.ts b/apps/web/src/components/docs/react-node-text.ts new file mode 100644 index 00000000..58df8766 --- /dev/null +++ b/apps/web/src/components/docs/react-node-text.ts @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { isValidElement } from "react"; + +/** Extracts plain text from nested React nodes. */ +export function extractText(node: ReactNode): string { + if (node === null || node === undefined || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(extractText).join(""); + if (isValidElement<{ children?: ReactNode }>(node)) return extractText(node.props.children); + return ""; +} diff --git a/apps/web/src/components/docs/sidebar-category-accordion.tsx b/apps/web/src/components/docs/sidebar-category-accordion.tsx deleted file mode 100644 index 3d4bcc94..00000000 --- a/apps/web/src/components/docs/sidebar-category-accordion.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -const isAutoCollapseEnabled = false; - -function isCategoryButton(button: HTMLButtonElement): boolean { - return button.querySelector(".docs-category-chevron") !== null; -} - -export function SidebarCategoryAccordion() { - useEffect(() => { - if (!isAutoCollapseEnabled) return; - - const onClick = (event: MouseEvent) => { - if (!(event.target instanceof Element)) return; - - const button = event.target.closest("button[aria-expanded]") as HTMLButtonElement | null; - if (!button || !isCategoryButton(button)) return; - - const sidebarRoot = button.closest("#nd-sidebar, #nd-sidebar-mobile"); - if (!sidebarRoot) return; - - // Wait for Fumadocs to update expanded state, then collapse siblings. - queueMicrotask(() => { - if (button.getAttribute("aria-expanded") !== "true") return; - - const categoryButtons = Array.from( - sidebarRoot.querySelectorAll("button[aria-expanded]"), - ).filter( - (item): item is HTMLButtonElement => - item instanceof HTMLButtonElement && isCategoryButton(item), - ); - - for (const sibling of categoryButtons) { - if (sibling !== button && sibling.getAttribute("aria-expanded") === "true") { - sibling.click(); - } - } - }); - }; - - document.addEventListener("click", onClick); - return () => { - document.removeEventListener("click", onClick); - }; - }, []); - - return null; -} diff --git a/apps/web/src/components/docs/sidebar-collapse-button.tsx b/apps/web/src/components/docs/sidebar-collapse-button.tsx deleted file mode 100644 index 98161298..00000000 --- a/apps/web/src/components/docs/sidebar-collapse-button.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useSidebar } from "fumadocs-ui/components/sidebar/base"; -import { PanelLeft } from "lucide-react"; - -import { Button } from "@/components/ui/button"; - -export function SidebarCollapseButton() { - const { collapsed, setCollapsed } = useSidebar(); - - return ( - - ); -} diff --git a/apps/web/src/components/docs/toc-footer.tsx b/apps/web/src/components/docs/toc-footer.tsx index c85c8220..f7d910da 100644 --- a/apps/web/src/components/docs/toc-footer.tsx +++ b/apps/web/src/components/docs/toc-footer.tsx @@ -2,8 +2,14 @@ import Link from "next/link"; import { URLs } from "@/lib/consts"; -const progressValue = 15; +const progressValue = 65; +/** Docs table-of-contents footer with roadmap progress link. + * + * Opens the roadmap in a new tab and displays `progressValue`. + * + * @returns JSX.Element + */ export function TocFooter() { return (
      diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx index 2f91087c..2d587a58 100644 --- a/apps/web/src/components/icons/index.tsx +++ b/apps/web/src/components/icons/index.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from "react"; +import { RiGithubFill, RiRobot2Line } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -570,6 +571,8 @@ export const Icons = { ), + GitHubIcon: ({ className }: { className?: string }) => , + LlmsIcon: ({ className }: { className?: string }) => , ClaudeIcon: ({ className }: { className?: string }) => ( - + @@ -28,7 +31,7 @@ export function MiniNavBar() { >
      - +
      diff --git a/apps/web/src/components/layout/navigation-bar.tsx b/apps/web/src/components/layout/navigation-bar.tsx index fa5c5434..be42e69b 100644 --- a/apps/web/src/components/layout/navigation-bar.tsx +++ b/apps/web/src/components/layout/navigation-bar.tsx @@ -1,23 +1,23 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown, ExternalLink, Github, Menu, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { RiArrowDownSLine, RiCloseLine, RiExternalLinkLine, RiMenuLine } from "react-icons/ri"; +import { Icons } from "@/components/icons"; import { SectionShell } from "@/components/layout/section"; import { Button } from "@/components/ui/button"; import { BrandMenu } from "@/components/web/brand-menu"; import { URLs } from "@/lib/consts"; -// ─── Shared nav link ───────────────────────────────────────────────── - interface NavItem { name: string; href: string; path?: string; external?: boolean; + icon?: React.ReactNode; } function NavLink({ @@ -46,19 +46,32 @@ function NavLink({ ); } -// ─── Data ──────────────────────────────────────────────────────────── - const navTabs: NavItem[] = [ - { name: "readme", href: "/" }, { name: "docs", href: "/docs", path: "/docs" }, + { name: "blog", href: "/blog", path: "/blog" }, + { name: "sponsors", href: "/sponsor", path: "/sponsor" }, ]; const dropdownLinks: NavItem[] = [ - { name: "Discord", href: URLs.discord, external: true }, - { name: "Twitter / X", href: URLs.x, external: true }, - { name: "LinkedIn", href: URLs.linkedin, external: true }, - { name: "Donate", href: "/donate", external: true }, - { name: "llms.txt", href: "/llms.txt", external: true }, + { + name: "Discord", + href: URLs.discord, + external: true, + icon: , + }, + { name: "Twitter / X", href: URLs.x, external: true, icon: }, + { + name: "LinkedIn", + href: URLs.linkedin, + external: true, + icon: , + }, + { + name: "llms.txt", + href: "/llms.txt", + external: true, + icon: , + }, ]; const mobileLinks: NavItem[] = [ @@ -66,19 +79,15 @@ const mobileLinks: NavItem[] = [ ...dropdownLinks.map((l) => ({ ...l, name: l.name.toLowerCase() })), ]; -// ─── Tab styles ────────────────────────────────────────────────────── - const tabBase = - "group/tab relative flex h-full items-center justify-center gap-1.5 px-5.5 py-3.5 transition-colors duration-150"; -const tabActive = "bg-background border-b-2 border-b-foreground/60"; + "group/tab relative flex h-full items-center justify-center gap-1.5 px-4 py-3.5 transition-colors duration-150"; +const tabActive = "bg-background"; const tabInactive = "hover:bg-foreground/[0.03] bg-transparent text-foreground/60 dark:text-foreground/40 hover:text-foreground/70"; const labelBase = "text-sm tracking-wider whitespace-nowrap uppercase transition-colors duration-150"; -// ─── Component ─────────────────────────────────────────────────────── - -export function NavigationBar({ stars: _stars }: { stars: number | null }) { +export function NavigationBar({ stars }: { stars?: number | null }) { const routerPathname = usePathname(); const [pathname, setPathname] = useState("/"); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -101,6 +110,8 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { closeTimeout.current = setTimeout(() => setLinksOpen(false), 150); }, []); + const linksMenuId = "header-links-menu"; + return ( <>
      @@ -109,20 +120,22 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.28, ease: "easeOut" }} - className="bg-background border-border pointer-events-auto w-full border-b lg:hidden" + className="bg-background pointer-events-auto w-full lg:hidden" > - - - + +
      + + +
      @@ -131,12 +144,12 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { initial={{ y: -10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.28, delay: 0.04, ease: "easeOut" }} - className="bg-background border-border pointer-events-auto relative hidden w-full items-stretch justify-center border-b lg:flex" + className="bg-background pointer-events-auto relative hidden w-full items-stretch justify-center lg:flex" > - +
      {/* Logo */} - + {/* Center tabs */}
      @@ -152,7 +165,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { > {item.name} @@ -174,13 +187,29 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { className="relative" onMouseEnter={openLinks} onMouseLeave={closeLinks} + onFocus={openLinks} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) closeLinks(); + }} > @@ -192,6 +221,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 4 }} transition={{ duration: 0.15 }} + id={linksMenuId} className="bg-background border-foreground/8 absolute top-full left-1/2 z-50 mt-px min-w-45 -translate-x-1/2 border py-1 shadow-lg" > {dropdownLinks.map((link) => ( @@ -201,10 +231,13 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { className="text-foreground/60 hover:text-foreground hover:bg-foreground/3 flex items-center justify-between px-4 py-2 text-sm transition-colors" onClick={() => setLinksOpen(false)} > - {link.name} - {link.external && ( - - )} + + + {link.icon} + + {link.name} + + ))} @@ -215,15 +248,16 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
      {/* Right */} -
      +
      @@ -242,7 +276,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { transition={{ duration: 0.15 }} className="bg-background/95 pointer-events-auto fixed inset-0 z-98 backdrop-blur-sm lg:hidden" > -
      +
      {mobileLinks.map((item, i) => ( setMobileMenuOpen(false)} > + {item.icon && ( + + {item.icon} + + )} {item.external && ( - + )} diff --git a/apps/web/src/components/layout/section.tsx b/apps/web/src/components/layout/section.tsx index e17c620f..47a5ea62 100644 --- a/apps/web/src/components/layout/section.tsx +++ b/apps/web/src/components/layout/section.tsx @@ -5,8 +5,6 @@ import { cn } from "@/lib/utils"; export const sectionShellWidth = "w-[calc(100%-1rem)] sm:w-[calc(100%-2rem)] md:w-[calc(100%-3rem)] lg:w-[calc(100%-4rem)] xl:w-full"; -// ─── Shared section line ───────────────────────────────────────────── - export function SectionLine({ orientation }: { orientation: "horizontal" | "vertical" }) { const isH = orientation === "horizontal"; return ( @@ -21,7 +19,7 @@ export function SectionLine({ orientation }: { orientation: "horizontal" | "vert export function SectionShell({ children, className }: { children: ReactNode; className?: string }) { return ( -
      +
      @@ -31,8 +29,6 @@ export function SectionShell({ children, className }: { children: ReactNode; cla ); } -// ─── Section (outer wrapper with solid borders) ────────────────────── - export function Section({ children, className, @@ -45,7 +41,7 @@ export function Section({ return ( {!last && ( -
      +
      )} @@ -54,8 +50,6 @@ export function Section({ ); } -// ─── SectionContent (padded content area) ──────────────────────────── - export function SectionContent({ children, className, @@ -66,11 +60,9 @@ export function SectionContent({ return
      {children}
      ; } -// ─── SectionSeparator (full viewport-width solid line) ─────────────── - export function SectionSeparator() { return ( -
      +
      ); diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx index f827b390..6b43f0c2 100644 --- a/apps/web/src/components/providers.tsx +++ b/apps/web/src/components/providers.tsx @@ -7,6 +7,11 @@ import { Toaster } from "sonner"; export function Providers({ children }: { children: ReactNode }) { return (
      -

      +

      Ready to add billing?

      -

      +

      One command to get started. Define your plans, connect Stripe, and ship billing in minutes.

      -
      @@ -317,8 +312,8 @@ function PlanCard({ active ? variant === "pro" ? "border-emerald-500/20 bg-emerald-500/[0.03]" - : "border-foreground/[0.12] bg-foreground/[0.02]" - : "border-foreground/[0.06]", + : "border-border bg-foreground/[0.02]" + : "border-border", )} >
      diff --git a/apps/web/src/components/sections/demo/demo-backend-panel.tsx b/apps/web/src/components/sections/demo/demo-backend-panel.tsx index 8c53c1d0..3e4f3fda 100644 --- a/apps/web/src/components/sections/demo/demo-backend-panel.tsx +++ b/apps/web/src/components/sections/demo/demo-backend-panel.tsx @@ -1,8 +1,8 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Loader2, User } from "lucide-react"; import type { ReactNode } from "react"; +import { RiLoader4Line, RiUserLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -19,13 +19,8 @@ export function DemoBackendPanel({ className?: string; }) { return ( -
      -
      +
      +
      Backend @@ -60,10 +55,10 @@ function FlowLog({ initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} transition={{ duration: 0.3, ease: "easeOut" }} - className="border-foreground/[0.08] shrink-0 overflow-hidden rounded-md border" + className="shrink-0 overflow-hidden rounded-xs border" > -
      - +
      + {card.trigger}
      @@ -82,13 +77,13 @@ function FlowLog({ initial={{ opacity: 0.5 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} - className="bg-foreground/[0.03] flex items-center overflow-hidden rounded px-2 py-2" + className="bg-foreground/[0.03] flex items-center overflow-hidden rounded-xs px-2 py-2" > {snippets[entry.snippet]} ) : entry.type === "pending" ? (
      - + {entry.label}
      ) : ( diff --git a/apps/web/src/components/sections/demo/demo-types.tsx b/apps/web/src/components/sections/demo/demo-types.tsx index 8619630b..69306905 100644 --- a/apps/web/src/components/sections/demo/demo-types.tsx +++ b/apps/web/src/components/sections/demo/demo-types.tsx @@ -1,18 +1,18 @@ -import { - CalendarCheck, - CalendarX, - CreditCard, - Database, - ExternalLink, - Link2, - RefreshCw, - Shield, - ShieldAlert, - Sparkles, - UserCheck, - Webhook, -} from "lucide-react"; import type { ReactNode } from "react"; +import { + RiBankCardLine, + RiCalendarCheckLine, + RiCalendarCloseLine, + RiDatabase2Line, + RiExternalLinkLine, + RiLink, + RiRefreshLine, + RiShieldFlashLine, + RiShieldLine, + RiSparklingLine, + RiUserFollowLine, + RiWebhookLine, +} from "react-icons/ri"; export type SnippetKey = "subscribe" | "check" | "report" | "portal" | "downgrade" | "resubscribe"; @@ -47,18 +47,18 @@ export function nextCardId() { } export const stepIcons: Record = { - user: , - "credit-card": , - webhook: , - database: , - link: , - "external-link": , - "calendar-x": , - "calendar-check": , - sparkles: , - shield: , - "shield-alert": , - refresh: , + user: , + "credit-card": , + webhook: , + database: , + link: , + "external-link": , + "calendar-x": , + "calendar-check": , + sparkles: , + shield: , + "shield-alert": , + refresh: , }; // Scripted replies for auto-play @@ -74,7 +74,7 @@ export const interactiveReplies = [ "Your plans are type-safe. Typo a plan ID and TypeScript catches it at build time.", "The dashboard mounts at /paykit in your app. No separate service to deploy.", "Webhooks are verified and deduplicated in the same DB transaction. No double charges.", - "You can swap from Stripe to Polar by changing one import. Your billing logic stays identical.", + "Stripe details stay inside PayKit. Your app keeps using plans, customers, and features.", "Every entitlement check is a single function call. No complex permission logic needed.", "PayKit runs inside your app. It's a library, not a platform. One npm install and you're set.", ]; diff --git a/apps/web/src/components/sections/demo/index.tsx b/apps/web/src/components/sections/demo/index.tsx index 0e20d417..8e7a7a3e 100644 --- a/apps/web/src/components/sections/demo/index.tsx +++ b/apps/web/src/components/sections/demo/index.tsx @@ -294,49 +294,47 @@ export function DemoSection({ snippets }: { snippets: Record - +
      -

      +

      How it works

      -

      - Click around the app below. Every interaction shows the PayKit code that runs and the - steps it orchestrates, in real time. -

      +
      +
      -
      - void handleUpgrade()} - onDowngrade={() => void handleDowngrade()} - onResubscribe={() => void handleResubscribe()} - onPortal={() => void handlePortal()} - /> -
      - -
      - -
      + void handleUpgrade()} + onDowngrade={() => void handleDowngrade()} + onResubscribe={() => void handleResubscribe()} + onPortal={() => void handlePortal()} + /> + +
      diff --git a/apps/web/src/components/sections/features-section.tsx b/apps/web/src/components/sections/features-section.tsx deleted file mode 100644 index bdade0a6..00000000 --- a/apps/web/src/components/sections/features-section.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Blocks, Cable, Database, Gauge, ShieldCheck, Webhook } from "lucide-react"; - -import { Section, SectionContent } from "@/components/layout/section"; - -const features = [ - { - icon: , - title: "Usage billing", - description: "Metered features with check() and report(). Zero network latency.", - }, - { - icon: , - title: "Webhooks handled", - description: "Verified, deduplicated, synced to your database automatically.", - }, - { - icon: , - title: "Any provider", - description: "Stripe, Polar, Creem, or your own custom provider. Swap with one import.", - }, - { - icon: , - title: "Plugins", - description: "Extend PayKit with dashboard, analytics, or build your own plugin.", - }, - { - icon: , - title: "Your database", - description: "Billing state in your Postgres, low latency, joinable with your tables.", - }, - { - icon: , - title: "Type-safe", - description: "Plan IDs, feature IDs, events. All inferred from your schema.", - }, -]; - -export function FeaturesSection() { - return ( -
      - -
      -

      - Features -

      -

      - Everything you need to add billing to your app. Nothing you don't. -

      -
      -
      - {features.map((feature) => ( -
      -
      - - {feature.icon} - -
      -

      {feature.title}

      -

      - {feature.description} -

      -
      -
      -
      - ))} -
      -
      -
      - ); -} diff --git a/apps/web/src/components/sections/feedback-content.ts b/apps/web/src/components/sections/feedback-content.ts new file mode 100644 index 00000000..cd1c3047 --- /dev/null +++ b/apps/web/src/components/sections/feedback-content.ts @@ -0,0 +1,120 @@ +export type Tweet = { + column: number; + name: string; + handle: string; + link: string; + avatar: string; + text: string; + checkmark: boolean; +}; + +export const tweets: Tweet[] = [ + { + column: 1, + name: "Guillermo Rauch", + handle: "rauchg", + link: "https://x.com/rauchg/status/2047218849754571200?s=20", + avatar: "https://pbs.twimg.com/profile_images/1783856060249595904/8TfcCN0r_200x200.jpg", + text: "👀", + checkmark: true, + }, + { + column: 1, + name: "Gruz", + handle: "damnGruz", + link: "https://x.com/damnGruz/status/2042264666135756991?s=20", + avatar: "https://pbs.twimg.com/profile_images/2008805464834973696/xjQGOfAs_200x200.jpg", + text: "sick!!! will try it", + checkmark: true, + }, + { + column: 2, + name: "Andrew Qu", + handle: "andrewqu", + link: "https://x.com/andrewqu/status/2057463195061948506?s=20", + avatar: "https://pbs.twimg.com/profile_images/1976812562143641600/e_E_wlXX_200x200.jpg", + text: "Paykit and opensec are too good", + checkmark: true, + }, + { + column: 4, + name: "Creem 🍦", + handle: "creem_io", + link: "https://x.com/creem_io/status/2042265241157857483?s=20", + avatar: "https://pbs.twimg.com/profile_images/2003816759975944192/3C1xR6B8_200x200.jpg", + text: "Looks amazing Max! 🙌", + checkmark: true, + }, + { + column: 2, + name: "Matteo Scotto", + handle: "442utopy", + link: "https://x.com/442utopy/status/2042500989660418128?s=20", + avatar: "https://pbs.twimg.com/profile_images/1898711224180674560/BrICyHKA_200x200.jpg", + text: "that looks amazing! Congrats for the launch", + checkmark: true, + }, + { + column: 2, + name: "Jonathan Wilke", + handle: "jonathan_wilke", + link: "https://x.com/jonathan_wilke/status/2042492766270234850?s=20", + avatar: "https://pbs.twimg.com/profile_images/1884529433979068416/AhfbeVEh_200x200.jpg", + text: "Honestly, this is probably the best lib project I have come across since better-auth.", + checkmark: true, + }, + { + column: 4, + name: "jan", + handle: "miaugladiator1", + link: "https://x.com/miaugladiator1/status/2039394313059086710?s=20", + avatar: "https://pbs.twimg.com/profile_images/2053193399180705794/7IMR_hhx_200x200.jpg", + text: "just saw paykit and it looks actually soooo cool have to try this out asap", + checkmark: true, + }, + { + column: 1, + name: "jordi", + handle: "jordienr", + link: "https://x.com/jordienr/status/2039374608286007503?s=20", + avatar: "https://pbs.twimg.com/profile_images/2053541405121769475/TUDez6zL_200x200.jpg", + text: "paykit looks very interesting, like a really good abstraction without lock in", + checkmark: true, + }, + { + column: 3, + name: "Lasse", + handle: "lassejv", + link: "https://x.com/lassejv/status/2042902834509656507?s=20", + avatar: "https://pbs.twimg.com/profile_images/2056849088126070784/MCWM6EuB_200x200.jpg", + text: "Holy shit paykit by @maxktz is really good.", + checkmark: true, + }, + { + column: 3, + name: "lakshmi", + handle: "simhskal", + link: "https://x.com/simhskal/status/2042818621492334663?s=20", + avatar: "https://pbs.twimg.com/profile_images/1997855512210427904/Spg8avde_200x200.jpg", + text: "very cool", + checkmark: true, + }, + { + column: 3, + name: "Leo", + handle: "leodev", + link: "https://x.com/leodev/status/2052489939292499986?s=20", + avatar: "https://pbs.twimg.com/profile_images/2060684155013201929/n2cPRDs7_200x200.jpg", + text: "You should check it out, really cool billing framework", + checkmark: true, + }, + { + column: 4, + name: "Saïd Aitmbarek", + handle: "SaidAitmbarek", + link: "https://x.com/SaidAitmbarek/status/2042568317169193320?s=20", + avatar: "https://pbs.twimg.com/profile_images/1891564978177454080/YzRSDzkw_200x200.jpg", + text: "Gotta try this one, brilliant work mate.", + checkmark: true, + }, +]; diff --git a/apps/web/src/components/sections/feedback-section.tsx b/apps/web/src/components/sections/feedback-section.tsx new file mode 100644 index 00000000..cbae9356 --- /dev/null +++ b/apps/web/src/components/sections/feedback-section.tsx @@ -0,0 +1,87 @@ +import { Section, SectionContent } from "@/components/layout/section"; +import { tweets } from "@/components/sections/feedback-content"; +import type { Tweet } from "@/components/sections/feedback-content"; + +const columns = [1, 2, 3, 4].map((column) => tweets.filter((tweet) => tweet.column === column)); + +function VerifiedIcon() { + return ( + + + + + ); +} + +function TweetCard({ tweet }: { tweet: Tweet }) { + return ( +
      + + View tweet by {tweet.name} + +
      + + {`${tweet.name}'s + +
      +
      + {tweet.name} + {tweet.checkmark && } +
      + + @{tweet.handle} + +
      +
      +
      +

      {tweet.text}

      +
      +
      + ); +} + +export function FeedbackSection() { + return ( +
      + +
      +

      + Feedback +

      +
      +
      +
      +
      + + +
      +
      + {columns.map((column, columnIndex) => ( +
      + {column.map((tweet) => ( + + ))} +
      + ))} +
      +
      +
      +
      + ); +} diff --git a/apps/web/src/components/sections/footer-section.tsx b/apps/web/src/components/sections/footer-section.tsx index 0541f986..6e92aeee 100644 --- a/apps/web/src/components/sections/footer-section.tsx +++ b/apps/web/src/components/sections/footer-section.tsx @@ -1,10 +1,10 @@ "use client"; -import { Github } from "lucide-react"; import Link from "next/link"; import { Icons } from "@/components/icons"; import { Section, SectionContent } from "@/components/layout/section"; +import { ThemeSwitcher } from "@/components/theme-switcher"; import { URLs } from "@/lib/consts"; const navLinks = [ @@ -17,7 +17,7 @@ const socialLinks = [ { label: "Discord", href: URLs.discord, icon: }, { label: "Twitter/X", href: URLs.x, icon: }, { label: "LinkedIn", href: URLs.linkedin, icon: }, - { label: "GitHub", href: URLs.githubRepo, icon: }, + { label: "GitHub", href: URLs.githubRepo, icon: }, ]; const prompt = encodeURIComponent(`Explain what PayKit (paykit.sh) is and why I should use it. @@ -93,6 +93,10 @@ export function FooterSection() {
      + {socialLinks.map((link) => (
      -
      +
      { + "subscription.activated": async ({ customer }) => { await sendEmail(customer.email, "Welcome to Pro!") }, } diff --git a/apps/web/src/components/sections/testimonials-section.tsx b/apps/web/src/components/sections/testimonials-section.tsx deleted file mode 100644 index dc678477..00000000 --- a/apps/web/src/components/sections/testimonials-section.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Icons } from "@/components/icons"; -import { Section, SectionContent } from "@/components/layout/section"; -import { cn } from "@/lib/utils"; - -const testimonials = [ - { - handle: "@alexdev", - avatar: "/testimonials/placeholder-1.png", - text: "Just integrated PayKit into our SaaS. Went from zero billing to subscriptions + usage limits in under an hour. The DX is insane.", - }, - { - handle: "@sarahbuilds", - avatar: "/testimonials/placeholder-2.png", - text: "PayKit replaced 800 lines of Stripe webhook code with a single subscribe() call. I'm never going back.", - }, - { - handle: "@marcuseng", - avatar: "/testimonials/placeholder-3.png", - text: "The fact that billing state lives in my own Postgres and I can just JOIN it with my tables is a game changer.", - }, - { - handle: "@devpriya", - avatar: "/testimonials/placeholder-4.png", - text: "We switched from Stripe's raw API to PayKit. Took 30 minutes. Our billing code went from 3 files to 1.", - }, - { - handle: "@joshcodes", - avatar: "/testimonials/placeholder-5.png", - text: "check() and report() for usage billing is exactly what I needed. No more custom middleware to gate features.", - }, - { - handle: "@emmaoss", - avatar: "/testimonials/placeholder-6.png", - text: "Open source billing that actually works. No vendor lock-in, no separate dashboard, just npm install and go.", - }, - { - handle: "@ryanships", - avatar: "/testimonials/placeholder-7.png", - text: "The type safety is incredible. Typo a plan ID and TypeScript catches it before you even run the code.", - }, - { - handle: "@linadev", - avatar: "/testimonials/placeholder-8.png", - text: "PayKit feels like what Stripe should have been for framework developers. Simple, embedded, type-safe.", - }, -]; - -// Split into 3 columns for masonry layout -const columns = [ - testimonials.filter((_, i) => i % 3 === 0), - testimonials.filter((_, i) => i % 3 === 1), - testimonials.filter((_, i) => i % 3 === 2), -]; - -function TestimonialCard({ handle, text }: { handle: string; avatar: string; text: string }) { - return ( -
      -
      -
      -
      -
      - - {handle} -
      -
      -

      {text}

      -
      -
      - ); -} - -export function TestimonialsSection() { - return ( -
      - -
      -

      - Loved by developers -

      -

      - See what developers are saying about PayKit. -

      -
      - - {/* Masonry columns with fade at edges */} -
      -
      -
      - {columns.map((column, colIdx) => ( -
      - {column.map((testimonial) => ( - - ))} -
      - ))} -
      -
      - -
      - ); -} diff --git a/apps/web/src/components/sidebar-content.tsx b/apps/web/src/components/sidebar-content.tsx index d9c17192..6850b70d 100644 --- a/apps/web/src/components/sidebar-content.tsx +++ b/apps/web/src/components/sidebar-content.tsx @@ -1,25 +1,25 @@ import type { Folder, Root } from "fumadocs-core/page-tree"; -import type { LucideIcon } from "lucide-react"; -import { - Binoculars, - Book, - CircleHelp, - Database, - Gauge, - Key, - KeyRound, - LucideAArrowDown, - Mail, - Mailbox, - Phone, - ScanFace, - ShieldCheck, - TriangleAlertIcon, - UserCircle, - UserSquare2, - Users2, -} from "lucide-react"; import type { ReactNode, SVGProps } from "react"; +import type { IconType } from "react-icons"; +import { + RiAccountBoxLine, + RiAccountCircleLine, + RiBookLine, + RiDatabase2Line, + RiErrorWarningLine, + RiInboxLine, + RiKey2Line, + RiKeyLine, + RiMailLine, + RiPhoneLine, + RiQuestionLine, + RiSearchEyeLine, + RiShieldCheckLine, + RiSortAlphabetAsc, + RiSpeedUpLine, + RiTeamLine, + RiUserSearchLine, +} from "react-icons/ri"; import { Icons } from "./icons"; @@ -31,7 +31,7 @@ export interface SubpageItem { export interface ListItem { title: string; href: string; - icon: ((props?: SVGProps) => ReactNode) | LucideIcon; + icon: ((props?: SVGProps) => ReactNode) | IconType; group?: boolean; separator?: boolean; isNew?: boolean; @@ -42,7 +42,7 @@ export interface ListItem { interface Content { title: string; href?: string; - Icon: ((props?: SVGProps) => ReactNode) | LucideIcon; + Icon: ((props?: SVGProps) => ReactNode) | IconType; isNew?: boolean; list: ListItem[]; } @@ -418,7 +418,7 @@ export const contents: Content[] = [ { title: "Social Sign-On", group: true, - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, href: "", }, { @@ -1090,13 +1090,13 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Other Relational Databases", href: "/docs/adapters/other-relational-databases", - icon: () => , + icon: () => , }, { group: true, title: "Adapters", href: "", - icon: () => , + icon: () => , }, { title: "Drizzle", @@ -1182,7 +1182,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Others", href: "", - icon: () => , + icon: () => , }, { title: "Community Adapters", @@ -1227,7 +1227,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Full Stack", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Astro", @@ -1269,7 +1269,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Backend", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Hono", @@ -1310,7 +1310,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Mobile & Desktop", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Expo", @@ -1342,38 +1342,38 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Authentication", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Two Factor", - icon: () => , + icon: () => , href: "/docs/plugins/2fa", }, { title: "Username", - icon: () => , + icon: () => , href: "/docs/plugins/username", }, { title: "Anonymous", - icon: () => , + icon: () => , href: "/docs/plugins/anonymous", }, { title: "Phone Number", - icon: () => , + icon: () => , href: "/docs/plugins/phone-number", }, { title: "Magic Link", href: "/docs/plugins/magic-link", - icon: () => , + icon: () => , }, { title: "Email OTP", href: "/docs/plugins/email-otp", - icon: () => , + icon: () => , }, { title: "Passkey", @@ -1436,7 +1436,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Authorization", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Admin", @@ -1458,7 +1458,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "API Key", href: "/docs/plugins/api-key", - icon: () => , + icon: () => , }, { title: "MCP", @@ -1494,7 +1494,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, }, { title: "Organization", - icon: () => , + icon: () => , href: "/docs/plugins/organization", }, { @@ -1566,11 +1566,11 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Utility", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Bearer", - icon: () => , + icon: () => , href: "/docs/plugins/bearer", }, { @@ -1707,7 +1707,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Payments", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Stripe", @@ -1935,7 +1935,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Create a Database Adapter", href: "/docs/guides/create-a-db-adapter", - icon: () => , + icon: () => , }, { title: "Browser Extension Guide", @@ -1976,7 +1976,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Optimize for Performance", href: "/docs/guides/optimizing-for-performance", - icon: () => , + icon: () => , }, { title: "Migration", @@ -2210,7 +2210,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Errors", href: "/docs/reference/errors", - icon: () => , + icon: () => , hasSubpages: true, subpages: [ { @@ -2275,23 +2275,23 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Resources", href: "/docs/reference/resources", - icon: () => , + icon: () => , }, { title: "Security", href: "/docs/reference/security", - icon: () => , + icon: () => , }, { title: "Telemetry", href: "/docs/reference/telemetry", - icon: () => , + icon: () => , }, { title: "FAQ", href: "/docs/reference/faq", - icon: () => , + icon: () => , }, ], }, diff --git a/apps/web/src/components/theme-switcher.tsx b/apps/web/src/components/theme-switcher.tsx index 46393d10..5056954f 100644 --- a/apps/web/src/components/theme-switcher.tsx +++ b/apps/web/src/components/theme-switcher.tsx @@ -1,28 +1,30 @@ "use client"; -import { Moon, Sun } from "lucide-react"; +import type { ComponentProps } from "react"; +import { RiMoonLine, RiSunLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { useThemeTransition } from "@/components/use-theme-transition"; +import { cn } from "@/lib/utils"; -export function ThemeSwitcher() { - const { activeTheme, mounted, toggleLabel, toggleTheme } = useThemeTransition(); - const buttonTheme = mounted ? activeTheme : "light"; +export function ThemeSwitcher({ + className, + size = "icon", + variant = "ghost", +}: Pick, "className" | "size" | "variant">) { + const { toggleLabel, toggleTheme } = useThemeTransition(); return ( ); @@ -214,7 +214,7 @@ function CarouselNext({ onClick={scrollNext} {...props} > - + Next slide ); diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index 8180b82a..cac7705e 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ "use client"; import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; -import { CheckIcon } from "lucide-react"; +import { RiCheckLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none [&>svg]:size-3.5" > - + ); diff --git a/apps/web/src/components/ui/code-block-content.tsx b/apps/web/src/components/ui/code-block-content.tsx index 678e289f..c767e477 100644 --- a/apps/web/src/components/ui/code-block-content.tsx +++ b/apps/web/src/components/ui/code-block-content.tsx @@ -1,17 +1,11 @@ import { highlight } from "fumadocs-core/highlight"; import type { HighlightOptions } from "fumadocs-core/highlight"; import type { ComponentProps } from "react"; -import type { BundledLanguage, BundledTheme } from "shiki"; +import type { BundledLanguage } from "shiki"; import { CodeBlock, type CodeBlockProps, Pre } from "@/components/ui/code-block"; +import { shikiHighlightOptions, shikiThemes } from "@/lib/shiki-themes"; import { cn } from "@/lib/utils"; -const defaultThemes = { - themes: { - light: "github-light" satisfies BundledTheme, - dark: "one-dark-pro" satisfies BundledTheme, - }, - defaultColor: false as const, -}; const defaultCodeBlockProps: CodeBlockProps = { className: @@ -40,18 +34,33 @@ function createPre(codeblock: CodeBlockProps, allowCopy: boolean) { export async function InlineCode({ lang, code }: { lang: string; code: string }) { const { codeToTokens } = await import("shiki"); - const { tokens } = await codeToTokens(code, { - lang: lang as BundledLanguage, - theme: "one-dark-pro", - }); + const [{ tokens: lightTokens }, { tokens: darkTokens }] = await Promise.all([ + codeToTokens(code, { + lang: lang as BundledLanguage, + theme: shikiThemes.light, + }), + codeToTokens(code, { + lang: lang as BundledLanguage, + theme: shikiThemes.dark, + }), + ]); return ( - {tokens[0]?.map((token, i) => ( - - {token.content} - - ))} + + {lightTokens[0]?.map((token, i) => ( + + {token.content} + + ))} + + + {darkTokens[0]?.map((token, i) => ( + + {token.content} + + ))} + ); } @@ -73,7 +82,7 @@ export async function CodeBlockContent({ const highlighted = await highlight(code, { lang, - ...defaultThemes, + ...shikiHighlightOptions, ...options, components: { pre: createPre(merged, allowCopy), diff --git a/apps/web/src/components/ui/code-block.tsx b/apps/web/src/components/ui/code-block.tsx index 575a9b43..87e75206 100644 --- a/apps/web/src/components/ui/code-block.tsx +++ b/apps/web/src/components/ui/code-block.tsx @@ -1,5 +1,5 @@ "use client"; -import { Check, Copy } from "lucide-react"; + import type { ButtonHTMLAttributes, ComponentProps, @@ -9,6 +9,7 @@ import type { RefObject, } from "react"; import { createContext, forwardRef, useCallback, useContext, useMemo, useRef } from "react"; +import { RiCheckLine, RiFileCopyLine } from "react-icons/ri"; import { buttonVariants } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -93,7 +94,7 @@ export function CodeBlock({ const areaRef = useRef(null); allowCopy ??= !isTab; const bg = cn( - "bg-fd-secondary", + "bg-secondary", keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)", ); const onCopy = useCallback(() => { @@ -111,15 +112,15 @@ export function CodeBlock({ dir="ltr" {...props} className={cn( - isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-fd-card", - "group shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm", + isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-card", + "group shiki relative overflow-hidden border text-sm shadow-sm outline-none", props.className, )} > {title ? (
      @@ -140,7 +141,7 @@ export function CodeBlock({
      ) : ( Actions({ - className: "absolute top-1 right-1 z-2 text-fd-muted-foreground", + className: "absolute top-1 right-1 z-2 text-muted-foreground", children: allowCopy && , }) )} @@ -149,7 +150,7 @@ export function CodeBlock({ {...viewportProps} className={cn( !isTab && [bg, "rounded-none border border-x-0 border-b-0"], - "text-sm overflow-auto max-h-[600px] bg-fd-muted/50 fd-scroll-container", + "max-h-[600px] overflow-auto bg-muted/50 text-sm", viewportProps.className, !title && "border-t-0", )} @@ -195,8 +196,10 @@ function CopyButton({ onClick={onClick} {...props} > - - + + ); } @@ -209,7 +212,7 @@ export function CodeBlockTabs({ ref, ...props }: ComponentProps) { ref={mergeRefs(containerRef, ref)} {...props} className={cn( - "bg-fd-card p-1 rounded-lg border overflow-hidden", + "overflow-hidden rounded-lg border bg-card p-1", !nested && "my-4", props.className, )} @@ -233,7 +236,7 @@ export function CodeBlockTabsList(props: ComponentProps) { @@ -247,11 +250,11 @@ export function CodeBlockTabsTrigger({ children, ...props }: ComponentProps -
      +
      {children} ); @@ -281,16 +284,16 @@ export const CodeBlockOld = forwardRef( ref={ref} {...props} className={cn( - "not-prose group fd-codeblock relative my-6 overflow-hidden rounded-lg border bg-fd-secondary/50 text-sm", + "group relative my-6 overflow-hidden rounded-lg border bg-secondary/50 text-sm", keepBackground && "bg-[var(--shiki-light-bg)] dark:bg-[var(--shiki-dark-bg)]", props.className, )} > {title ? ( -
      +
      {icon ? (
      ( {typeof icon !== "string" ? icon : null}
      ) : null} -
      {title}
      +
      {title}
      {allowCopy ? : null}
      ) : ( diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 6addd25d..dc5e635c 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -1,8 +1,8 @@ "use client"; import { Combobox as ComboboxPrimitive } from "@base-ui/react"; -import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowDownSLine, RiCheckLine, RiCloseLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { @@ -27,7 +27,7 @@ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Tr {...props} > {children} - + ); } @@ -40,7 +40,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { className={cn(className)} {...props} > - + ); } @@ -143,7 +143,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item. } > - + ); @@ -232,7 +232,7 @@ function ComboboxChip({ className="-ml-1 opacity-50 hover:opacity-100" data-slot="combobox-chip-remove" > - + )} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 78e76940..04c49fbc 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -1,8 +1,8 @@ "use client"; import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiCheckLine, RiSearchLine } from "react-icons/ri"; import { Dialog, @@ -73,7 +73,7 @@ function CommandInput({ {...props} /> - +
      @@ -150,7 +150,7 @@ function CommandItem({ {...props} > {children} - + ); } diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index d6b56246..2ce2d233 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -1,8 +1,8 @@ "use client"; import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; -import { ChevronRightIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowRightSLine, RiCheckLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -135,7 +135,7 @@ function ContextMenuSubTrigger({ {...props} > {children} - + ); } @@ -173,7 +173,7 @@ function ContextMenuCheckboxItem({ > - + {children} @@ -205,7 +205,7 @@ function ContextMenuRadioItem({ > - + {children} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 3b29b13d..41745c24 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,8 +1,8 @@ "use client"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; -import { XIcon } from "lucide-react"; import * as React from "react"; +import { RiCloseLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -61,7 +61,7 @@ function DialogContent({ data-slot="dialog-close" render={
      ); } diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx index bedfd43f..a63ce43f 100644 --- a/apps/web/src/components/ui/menubar.tsx +++ b/apps/web/src/components/ui/menubar.tsx @@ -2,8 +2,8 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"; -import { CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiCheckLine } from "react-icons/ri"; import { DropdownMenu, @@ -121,7 +121,7 @@ function MenubarCheckboxItem({ > - + {children} @@ -153,7 +153,7 @@ function MenubarRadioItem({ > - + {children} diff --git a/apps/web/src/components/ui/native-select.tsx b/apps/web/src/components/ui/native-select.tsx index cc077ccf..9cb9ab56 100644 --- a/apps/web/src/components/ui/native-select.tsx +++ b/apps/web/src/components/ui/native-select.tsx @@ -1,5 +1,5 @@ -import { ChevronDownIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowDownSLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -23,7 +23,7 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40" {...props} /> -