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/apps/web/content/docs/concepts/cli.mdx b/apps/web/content/docs/cli.mdx similarity index 89% rename from apps/web/content/docs/concepts/cli.mdx rename to apps/web/content/docs/cli.mdx index ce624a05..efe60c09 100644 --- a/apps/web/content/docs/concepts/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -5,9 +5,11 @@ 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 configures Stripe, then generates: @@ -17,9 +19,11 @@ 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: @@ -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 cf61106f..00000000 --- a/apps/web/content/docs/concepts/payment-providers.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Payment Providers -description: How PayKit keeps Stripe-native IDs internal. ---- - -PayKit uses Stripe for billing. Your app works with plans, customers, and subscriptions. PayKit translates those into Stripe-native operations behind the scenes. - -## How it works - -Pass your Stripe keys to `createPayKit({ stripe })`. PayKit handles Stripe API calls, webhook processing, and product syncing. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - stripe: { - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }, -}); -``` - -## Stripe - -Stripe 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. - -## Stripe 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 keeps Stripe details out of your application code. - 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/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 89% rename from apps/web/content/docs/get-started/installation.mdx rename to apps/web/content/docs/installation.mdx index 8fd9e721..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,7 +39,7 @@ export const paykit = createPayKit({ -## Configure Stripe +Configure Stripe PayKit uses Stripe for billing. Pass your Stripe keys directly to the PayKit instance. @@ -59,7 +56,7 @@ export const paykit = createPayKit({ -## 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. @@ -77,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. @@ -135,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. @@ -167,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. @@ -221,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/get-started/index.mdx b/apps/web/content/docs/introduction.mdx similarity index 95% rename from apps/web/content/docs/get-started/index.mdx rename to apps/web/content/docs/introduction.mdx index 3ebffd07..336ff731 100644 --- a/apps/web/content/docs/get-started/index.mdx +++ b/apps/web/content/docs/introduction.mdx @@ -10,5 +10,3 @@ PayKit is an embedded Stripe billing framework for TypeScript apps. It provides 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/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/meta.json b/apps/web/content/docs/providers/meta.json deleted file mode 100644 index a058739d..00000000 --- a/apps/web/content/docs/providers/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Providers", - "pages": ["stripe"] -} diff --git a/apps/web/content/docs/providers/stripe.mdx b/apps/web/content/docs/providers/stripe.mdx deleted file mode 100644 index a7e18b23..00000000 --- a/apps/web/content/docs/providers/stripe.mdx +++ /dev/null @@ -1,167 +0,0 @@ ---- -title: Stripe -description: Configure Stripe for PayKit, set up webhooks, sync products, and use the customer portal. ---- - -Stripe is built into PayKit. PayKit handles Stripe API interactions, webhook processing, and product syncing. - -## Configuration - -Pass Stripe config to `createPayKit` with your secret key and webhook secret. - -```ts title="paykit.ts" -import { createPayKit } from "paykitjs"; - -export const paykit = createPayKit({ - // ... - 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({ - // ... - 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({ - // ... - 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..b817925a --- /dev/null +++ b/apps/web/src/app/(marketing)/blog/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: "Blog", + description: "PayKit blog is coming soon.", + alternates: { + canonical: "/blog", + }, +}; + +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 index af140200..3f7fe134 100644 --- a/apps/web/src/app/api/og/[[...slug]]/route.tsx +++ b/apps/web/src/app/api/og/[[...slug]]/route.tsx @@ -6,6 +6,8 @@ import { ImageResponse } from "next/og"; import { source } from "@/lib/source"; +export const revalidate = false; + export function generateStaticParams() { return source.generateParams(); } diff --git a/apps/web/src/app/docs/[[...slug]]/page.tsx b/apps/web/src/app/docs/[[...slug]]/page.tsx index 09cc1f89..b2fbb6ad 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,17 +59,12 @@ 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(); + const ogPath = slug?.length ? `/${slug.join("/")}` : ""; + return { title: page.data.title, description: page.data.description, @@ -108,7 +73,7 @@ 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..12251c7b 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"; @@ -50,7 +50,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..1cbc8b96 100644 --- a/apps/web/src/components/docs/copy-markdown-button.tsx +++ b/apps/web/src/components/docs/copy-markdown-button.tsx @@ -1,34 +1,92 @@ "use client"; -import { Check, Copy } from "lucide-react"; +import Link from "next/link"; import { useCallback, 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(); 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 e46a720a..189da22e 100644
--- a/apps/web/src/components/docs/docs-icons.tsx
+++ b/apps/web/src/components/docs/docs-icons.tsx
@@ -1,81 +1,80 @@
-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 {
+  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"]);
@@ -142,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..916d7ef1 --- /dev/null +++ b/apps/web/src/components/docs/docs-mdx-components.tsx @@ -0,0 +1,310 @@ +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 { 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"]) { + return ( + children + ?.toString() + .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 ea0a9cdf..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: , + 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..0c0605bd --- /dev/null +++ b/apps/web/src/components/docs/mdx-text.tsx @@ -0,0 +1,148 @@ +"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..60f5c8a7 --- /dev/null +++ b/apps/web/src/components/docs/package-command-utils.ts @@ -0,0 +1,34 @@ +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": + return manager === "npm" ? `npm run ${command.args}` : `${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..c0d2b7f4 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..343004cf 100644 --- a/apps/web/src/components/docs/toc-footer.tsx +++ b/apps/web/src/components/docs/toc-footer.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { URLs } from "@/lib/consts"; -const progressValue = 15; +const progressValue = 65; 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..cd0d7507 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); @@ -109,20 +118,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 +142,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 +163,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { > {item.name} @@ -180,7 +191,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { className={`${tabBase} gap-1 ${linksOpen ? "text-foreground/70" : tabInactive}`} > links - @@ -201,10 +212,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 +229,16 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
      {/* Right */} -
      +
      @@ -242,7 +257,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..90ebd72d 100644 --- a/apps/web/src/components/layout/section.tsx +++ b/apps/web/src/components/layout/section.tsx @@ -21,7 +21,7 @@ export function SectionLine({ orientation }: { orientation: "horizontal" | "vert export function SectionShell({ children, className }: { children: ReactNode; className?: string }) { return ( -
      +
      @@ -45,7 +45,7 @@ export function Section({ return ( {!last && ( -
      +
      )} @@ -66,11 +66,11 @@ export function SectionContent({ return
      {children}
      ; } -// ─── SectionSeparator (full viewport-width solid line) ─────────────── +// ─── SectionSeparator ──────────────────────────────────────────────── 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 6d6838e8..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 diff --git a/apps/web/src/components/sections/demo/index.tsx b/apps/web/src/components/sections/demo/index.tsx index 0e20d417..bdbd7ddc 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 b995e894..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: "Stripe built in", - description: "Subscriptions, webhooks, portal, and product sync without adapter setup.", - }, - { - 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} /> -