diff --git a/.changeset/strict-bushes-change.md b/.changeset/strict-bushes-change.md
new file mode 100644
index 00000000..439fdd99
--- /dev/null
+++ b/.changeset/strict-bushes-change.md
@@ -0,0 +1,5 @@
+---
+"chatbot-with-billing-with-clerk": patch
+---
+
+feat(examples): new example with clerk auth
diff --git a/examples/chatbot-with-billing-with-clerk/.env.example b/examples/chatbot-with-billing-with-clerk/.env.example
new file mode 100644
index 00000000..57f8f9ed
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.env.example
@@ -0,0 +1,15 @@
+# Clerk keys — https://dashboard.clerk.com
+NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=****
+CLERK_SECRET_KEY=****
+
+# required for non-vercel deployments, vercel uses OIDC automatically
+# https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=****
+
+# https://vercel.com/docs/postgres
+POSTGRES_URL=****
+
+# https://polar.sh — billing destination
+# POLAR_SERVER can be 'sandbox' (default) or 'production'
+POLAR_ACCESS_TOKEN=****
+POLAR_SERVER=sandbox
diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml
new file mode 100644
index 00000000..9fd04890
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml
@@ -0,0 +1,26 @@
+name: Lint
+on:
+ push:
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+ strategy:
+ matrix:
+ node-version: [20]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.32.1
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Install dependencies
+ run: pnpm install --no-frozen-lockfile
+ - name: Run lint
+ run: pnpm run lint
+ - name: Run type check
+ run: pnpm run check-types
diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml
new file mode 100644
index 00000000..faf9f4d0
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml
@@ -0,0 +1,58 @@
+name: Playwright Tests
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ test:
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+ env:
+ AUTH_SECRET: ${{ secrets.OSS_AUTH_SECRET }}
+ AI_GATEWAY_API_KEY: ${{ secrets.OSS_AI_GATEWAY_API_KEY }}
+ POSTGRES_URL: ${{ secrets.OSS_POSTGRES_URL }}
+ POLAR_ACCESS_TOKEN: ${{ secrets.OSS_POLAR_ACCESS_TOKEN }}
+ POLAR_SERVER: 'sandbox'
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v3
+ with:
+ version: 10.32.1
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Install dependencies
+ run: pnpm install --no-frozen-lockfile
+
+ - name: Build application
+ run: pnpm run build
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v3
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
+
+ - name: Install Playwright Browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: pnpm run test:setup
+
+ - name: Run Playwright tests
+ run: pnpm run test:e2e
+
+ - uses: actions/upload-artifact@v4
+ if: always() && !cancelled()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 7
diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml
new file mode 100644
index 00000000..6042f22e
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml
@@ -0,0 +1,39 @@
+name: Pull Upstream Template
+
+on:
+ schedule:
+ - cron: '0 0 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ pull-and-update:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v3
+ with:
+ version: 9
+
+ - name: Run pull script
+ run: node scripts/pull-upstream.js
+
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@v7
+ with:
+ commit-message: 'chore: sync upstream template from narevai/ai-billing'
+ branch: chore/sync-upstream
+ delete-branch: true
+ title: 'chore: sync upstream template from narevai/ai-billing'
+ body: 'Automated sync of upstream template changes from narevai/ai-billing.'
diff --git a/examples/chatbot-with-billing-with-clerk/.gitignore b/examples/chatbot-with-billing-with-clerk/.gitignore
new file mode 100644
index 00000000..b24a32e0
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.gitignore
@@ -0,0 +1,27 @@
+.pnpm-store/
+node_modules
+.pnp
+.pnp.js
+coverage
+.next/
+out/
+build
+.DS_Store
+*.pem
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+.env.local
+.env.development.local
+.env.production.local
+.turbo
+.env
+.vercel
+.env*.local
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/*
+next-env.d.ts
+tsconfig.tsbuildinfo
diff --git a/examples/chatbot-with-billing-with-clerk/.oxlintrc.json b/examples/chatbot-with-billing-with-clerk/.oxlintrc.json
new file mode 100644
index 00000000..640a04b3
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/.oxlintrc.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
+ "plugins": ["typescript"],
+ "categories": {
+ "correctness": "warn",
+ "suspicious": "warn"
+ },
+ "rules": {
+ "eslint/no-shadow": "off",
+ "eslint/no-loss-of-precision": "off",
+ "eslint/no-unused-vars": "off"
+ },
+ "ignorePatterns": ["**/dist/**", "**/coverage/**", "**/.next/**"]
+}
diff --git a/examples/chatbot-with-billing-with-clerk/README.md b/examples/chatbot-with-billing-with-clerk/README.md
new file mode 100644
index 00000000..44eb8e09
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/README.md
@@ -0,0 +1,69 @@
+
+
+ Chatbot with AI Billing (Clerk)
+
+
+
+ This is an open-source template built with Next.js, the AI SDK, Clerk for authentication, and the ai-billing package. It helps you quickly build powerful chatbot applications with built-in monetization and billing capabilities.
+
+
+
+ Read Docs ·
+ Features ·
+ Model Providers ·
+ Deploy Your Own ·
+ Running locally
+
+
+
+## Features
+
+- **[ai-billing](https://narev.ai/docs/sdk/ai-billing)**
+ - Seamless monetization and billing integration for your AI applications
+ - Powered by [narev.ai](https://narev.ai)
+- [Next.js](https://nextjs.org) App Router
+ - Advanced routing for seamless navigation and performance
+ - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
+- [AI SDK](https://ai-sdk.dev/docs/introduction)
+ - Unified API for generating text, structured objects, and tool calls with LLMs
+ - Hooks for building dynamic chat and generative user interfaces
+ - Supports OpenAI, Anthropic, Google, xAI, and other model providers via AI Gateway
+- [shadcn/ui](https://ui.shadcn.com)
+ - Styling with [Tailwind CSS](https://tailwindcss.com)
+ - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
+- Data Persistence
+ - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
+- [Clerk](https://clerk.com)
+ - Authentication with email verification code (passwordless sign-in)
+
+## Model Providers
+
+This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI.
+
+### AI Gateway Authentication
+
+**For Vercel deployments**: Authentication is handled automatically via OIDC tokens.
+
+**For non-Vercel deployments**: You need to provide an AI Gateway API key by setting the `AI_GATEWAY_API_KEY` environment variable in your `.env.local` file.
+
+With the [AI SDK](https://ai-sdk.dev/docs/introduction), you can also switch to direct LLM providers like [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://ai-sdk.dev/providers/ai-sdk-providers) with just a few lines of code.
+
+## Deploy Your Own
+
+You can deploy your own version of Chatbot with billing to Vercel with one click:
+
+[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnarevai%2Fchatbot-with-billing&env=NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,AI_GATEWAY_API_KEY,POSTGRES_URL,POLAR_ACCESS_TOKEN,POLAR_SERVER&envDefaults=%7B%22POLAR_SERVER%22%3A%22sandbox%22%7D)
+
+## Running locally
+
+You will need to use the environment variables [defined in `.env.example`](.env.example) to run Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
+
+> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts.
+
+```bash
+pnpm install
+pnpm db:migrate # Setup database or apply latest database changes
+pnpm dev
+```
+
+Your app template should now be running on [localhost:3000](http://localhost:3000).
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts
new file mode 100644
index 00000000..455b97c6
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts
@@ -0,0 +1,50 @@
+'use server';
+
+import { generateText, type UIMessage } from 'ai';
+import { createOpenAI } from '@ai-sdk/openai';
+import { auth } from '@clerk/nextjs/server';
+import type { VisibilityType } from '@/components/chat/visibility-selector';
+import { titlePrompt } from '@/lib/ai/prompts';
+import {
+ getChatById,
+ getUserId,
+ updateChatVisibilityById,
+} from '@/lib/db/queries';
+import { getTextFromMessage } from '@/lib/utils';
+
+export async function generateTitleFromUserMessage({
+ message,
+}: {
+ message: UIMessage;
+}) {
+ const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
+ const { text } = await generateText({
+ model: openai('gpt-4o-mini'),
+ system: titlePrompt,
+ prompt: getTextFromMessage(message),
+ });
+ return text
+ .replace(/^[#*"\s]+/, '')
+ .replace(/["]+$/, '')
+ .trim();
+}
+
+export async function updateChatVisibility({
+ chatId,
+ visibility,
+}: {
+ chatId: string;
+ visibility: VisibilityType;
+}) {
+ const dbUserId = await getUserId();
+ if (!dbUserId) {
+ throw new Error('Unauthorized');
+ }
+
+ const chat = await getChatById({ id: chatId });
+ if (!chat || chat.userId !== dbUserId) {
+ throw new Error('Unauthorized');
+ }
+
+ await updateChatVisibilityById({ chatId, visibility });
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts
new file mode 100644
index 00000000..f7c9a45e
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts
@@ -0,0 +1,33 @@
+import { auth } from '@clerk/nextjs/server';
+import { deleteChatById, getChatById, getUserId } from '@/lib/db/queries';
+import { ChatbotError } from '@/lib/errors';
+
+export async function DELETE(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return new ChatbotError('bad_request:api').toResponse();
+ }
+
+ const { userId } = await auth();
+
+ if (!userId) {
+ return new ChatbotError('unauthorized:chat').toResponse();
+ }
+
+ const dbUserId = await getUserId();
+ if (!dbUserId) {
+ return new ChatbotError('unauthorized:chat').toResponse();
+ }
+
+ const chat = await getChatById({ id });
+
+ if (chat?.userId !== dbUserId) {
+ return new ChatbotError('forbidden:chat').toResponse();
+ }
+
+ const deletedChat = await deleteChatById({ id });
+
+ return Response.json(deletedChat, { status: 200 });
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts
new file mode 100644
index 00000000..275be77c
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts
@@ -0,0 +1,58 @@
+import type { NextRequest } from 'next/server';
+import { auth } from '@clerk/nextjs/server';
+import {
+ deleteAllChatsByUserId,
+ getChatsByUserId,
+ getUserId,
+} from '@/lib/db/queries';
+import { ChatbotError } from '@/lib/errors';
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+
+ const limit = Math.min(
+ Math.max(Number.parseInt(searchParams.get('limit') || '10', 10), 1),
+ 50,
+ );
+ const startingAfter = searchParams.get('starting_after');
+ const endingBefore = searchParams.get('ending_before');
+
+ if (startingAfter && endingBefore) {
+ return new ChatbotError(
+ 'bad_request:api',
+ 'Only one of starting_after or ending_before can be provided.',
+ ).toResponse();
+ }
+
+ const { userId } = await auth();
+
+ if (!userId) {
+ return new ChatbotError('unauthorized:chat').toResponse();
+ }
+
+ const dbUserId = await getUserId();
+ if (!dbUserId) {
+ return new ChatbotError('unauthorized:chat').toResponse();
+ }
+
+ const chats = await getChatsByUserId({
+ id: dbUserId,
+ limit,
+ startingAfter,
+ endingBefore,
+ });
+
+ return Response.json(chats);
+}
+
+export async function DELETE() {
+ const dbUserId = await getUserId();
+
+ if (!dbUserId) {
+ return new ChatbotError('unauthorized:chat').toResponse();
+ }
+
+ const result = await deleteAllChatsByUserId({ userId: dbUserId });
+
+ return Response.json(result, { status: 200 });
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx
new file mode 100644
index 00000000..346731c8
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx
@@ -0,0 +1,36 @@
+import { Suspense } from 'react';
+import { redirect } from 'next/navigation';
+import type { UIMessage } from 'ai';
+import { auth } from '@clerk/nextjs/server';
+import { ChatShell } from '@/components/chat/shell';
+import { getChatById, getMessagesByChatId, getUserId } from '@/lib/db/queries';
+import { convertToUIMessages } from '@/lib/utils';
+
+async function ChatContent({ id }: { id: string }) {
+ const { userId } = await auth();
+ if (!userId) redirect('/sign-in');
+
+ const dbUserId = await getUserId();
+ if (!dbUserId) redirect('/sign-in');
+
+ const chat = await getChatById({ id });
+ if (chat && chat.userId !== dbUserId) redirect('/');
+
+ const dbMessages = chat ? await getMessagesByChatId({ id }) : [];
+ const messages = convertToUIMessages(dbMessages) as UIMessage[];
+
+ return ;
+}
+
+export default async function ChatPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx
new file mode 100644
index 00000000..4d3a86e8
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx
@@ -0,0 +1,7 @@
+export default function ChatLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx
new file mode 100644
index 00000000..8e38c090
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react';
+import { auth } from '@clerk/nextjs/server';
+import { redirect } from 'next/navigation';
+import { generateUUID } from '@/lib/utils';
+
+async function ChatRedirect() {
+ const { userId } = await auth();
+ if (!userId) redirect('/sign-in');
+ redirect(`/chat/${generateUUID()}`);
+ return null;
+}
+
+export default function ChatPage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts
new file mode 100644
index 00000000..c949b990
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts
@@ -0,0 +1,29 @@
+'use server';
+
+import {
+ saveChat as _saveChat,
+ saveMessages as _saveMessages,
+ updateChatTitleById as _updateChatTitleById,
+} from '@/lib/db/queries';
+import type { DBMessage } from '@/lib/db/schema';
+import type { VisibilityType } from '@/components/chat/visibility-selector';
+
+export async function saveChat(args: {
+ id: string;
+ userId: string;
+ title: string;
+ visibility: VisibilityType;
+}) {
+ return _saveChat(args);
+}
+
+export async function saveMessages(args: { messages: DBMessage[] }) {
+ return _saveMessages(args);
+}
+
+export async function updateChatTitleById(args: {
+ chatId: string;
+ title: string;
+}) {
+ return _updateChatTitleById(args);
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx
new file mode 100644
index 00000000..d24a1f36
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx
@@ -0,0 +1,44 @@
+import '@/lib/ai/chat-setup';
+import { cookies } from 'next/headers';
+import Script from 'next/script';
+import { Suspense } from 'react';
+import { Toaster } from 'sonner';
+import { AppSidebar } from '@/components/chat/app-sidebar';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { auth } from '@clerk/nextjs/server';
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+ }>
+ {children}
+
+ >
+ );
+}
+
+async function SidebarShell({ children }: { children: React.ReactNode }) {
+ const [{ userId }, cookieStore] = await Promise.all([auth(), cookies()]);
+ const isCollapsed = cookieStore.get('sidebar_state')?.value !== 'true';
+
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/opengraph-image.png b/examples/chatbot-with-billing-with-clerk/app/(chat)/opengraph-image.png
new file mode 100644
index 00000000..c8402717
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/app/(chat)/opengraph-image.png differ
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/page.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/page.tsx
new file mode 100644
index 00000000..698ff6c2
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from 'react';
+import { UsageContent } from '@/components/usage/usage-content';
+import { auth } from '@clerk/nextjs/server';
+
+async function UsagePageContent() {
+ const { userId } = await auth();
+ return ;
+}
+
+export default function UsagePage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/twitter-image.png b/examples/chatbot-with-billing-with-clerk/app/(chat)/twitter-image.png
new file mode 100644
index 00000000..79fbc0f9
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/app/(chat)/twitter-image.png differ
diff --git a/examples/chatbot-with-billing-with-clerk/app/favicon.ico b/examples/chatbot-with-billing-with-clerk/app/favicon.ico
new file mode 100644
index 00000000..7452b5dc
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/app/favicon.ico differ
diff --git a/examples/chatbot-with-billing-with-clerk/app/globals.css b/examples/chatbot-with-billing-with-clerk/app/globals.css
new file mode 100644
index 00000000..41437746
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/globals.css
@@ -0,0 +1,500 @@
+@import "tailwindcss";
+@import "katex/dist/katex.min.css";
+
+@source "../node_modules/streamdown/dist/index.js";
+
+@custom-variant dark (&:is(.dark, .dark *));
+
+@plugin "tailwindcss-animate";
+@plugin "@tailwindcss/typography";
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(0.985 0 0);
+ --foreground: oklch(0.12 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.12 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.12 0 0);
+ --primary: oklch(0.12 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.965 0 0);
+ --secondary-foreground: oklch(0.38 0 0);
+ --muted: oklch(0.94 0 0);
+ --muted-foreground: oklch(0.58 0 0);
+ --accent: oklch(0.965 0 0);
+ --accent-foreground: oklch(0.12 0 0);
+ --destructive: oklch(0.55 0.15 25);
+ --border: oklch(0.9 0 0);
+ --input: oklch(0.9 0 0);
+ --ring: oklch(0.5 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.97 0 0);
+ --sidebar-foreground: oklch(0.38 0 0);
+ --sidebar-primary: oklch(0.12 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.12 0 0 / 0.06);
+ --sidebar-accent-foreground: oklch(0.12 0 0);
+ --sidebar-border: oklch(0.88 0 0);
+ --sidebar-ring: oklch(0.5 0 0);
+
+ --shadow-card: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03);
+ --shadow-float:
+ 0 8px 24px -6px oklch(0 0 0 / 0.1), 0 2px 8px -2px oklch(0 0 0 / 0.04);
+ --shadow-composer: 0 1px 2px oklch(0 0 0 / 0.04);
+ --shadow-composer-focus:
+ 0 0 0 1px oklch(0 0 0 / 0.06), 0 2px 8px -2px oklch(0 0 0 / 0.06);
+ --shadow-inset: inset 0 1px 1px oklch(0 0 0 / 0.03);
+ --shadow-glow: 0 0 20px oklch(0 0 0 / 0.08);
+
+ --ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
+ --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.dark {
+ --background: oklch(0.195 0 0);
+ --foreground: oklch(0.94 0 0);
+ --card: oklch(0.225 0 0);
+ --card-foreground: oklch(0.94 0 0);
+ --popover: oklch(0.225 0 0);
+ --popover-foreground: oklch(0.94 0 0);
+ --primary: oklch(0.94 0 0);
+ --primary-foreground: oklch(0.195 0 0);
+ --secondary: oklch(0.26 0 0);
+ --secondary-foreground: oklch(0.75 0 0);
+ --muted: oklch(0.165 0 0);
+ --muted-foreground: oklch(0.6 0 0);
+ --accent: oklch(0.26 0 0);
+ --accent-foreground: oklch(0.94 0 0);
+ --destructive: oklch(0.7 0.15 25);
+ --border: oklch(0.27 0 0);
+ --input: oklch(0.27 0 0);
+ --ring: oklch(0.45 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.175 0 0);
+ --sidebar-foreground: oklch(0.78 0 0);
+ --sidebar-primary: oklch(0.94 0 0);
+ --sidebar-primary-foreground: oklch(0.195 0 0);
+ --sidebar-accent: oklch(0.94 0 0 / 0.06);
+ --sidebar-accent-foreground: oklch(0.94 0 0);
+ --sidebar-border: oklch(0.25 0 0);
+ --sidebar-ring: oklch(0.45 0 0);
+
+ --shadow-card:
+ inset 0 1px 0 oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2),
+ 0 0.5px 1px oklch(0 0 0 / 0.15);
+ --shadow-float:
+ 0 0 0 1px oklch(1 0 0 / 0.06), 0 16px 48px -6px oklch(0 0 0 / 0.35),
+ 0 6px 12px -2px oklch(0 0 0 / 0.2);
+ --shadow-composer:
+ 0 1px 3px oklch(0 0 0 / 0.2), inset 0 1px 0 oklch(1 0 0 / 0.03);
+ --shadow-composer-focus:
+ 0 0 0 1px oklch(1 0 0 / 0.1), 0 4px 16px -4px oklch(0 0 0 / 0.3),
+ inset 0 1px 0 oklch(1 0 0 / 0.04);
+}
+
+@layer base {
+ * {
+ @apply border-border ring-0;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-feature-settings: "ss01", "ss02", "cv01";
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ }
+}
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--border);
+ }
+}
+
+@layer base {
+ body {
+ overflow-x: hidden;
+ position: relative;
+ }
+
+ html {
+ overflow-x: hidden;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ letter-spacing: -0.025em;
+ line-height: 1.2;
+ }
+
+ p {
+ line-height: 1.6;
+ }
+}
+
+button:focus-visible,
+select:focus-visible,
+[role="button"]:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+ outline: none;
+}
+
+@utility text-balance {
+ text-wrap: balance;
+}
+
+@utility -webkit-overflow-scrolling-touch {
+ -webkit-overflow-scrolling: touch;
+}
+
+@utility touch-pan-y {
+ touch-action: pan-y;
+}
+
+@utility overscroll-behavior-contain {
+ overscroll-behavior: contain;
+}
+
+@utility no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+@layer utilities {
+ :root {
+ --foreground-rgb: 0, 0, 0;
+ --background-start-rgb: 214, 219, 220;
+ --background-end-rgb: 255, 255, 255;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --foreground-rgb: 255, 255, 255;
+ --background-start-rgb: 0, 0, 0;
+ --background-end-rgb: 0, 0, 0;
+ }
+ }
+}
+
+.skeleton {
+ * {
+ pointer-events: none !important;
+ }
+
+ *[class^="text-"] {
+ color: transparent;
+ @apply rounded-md bg-foreground/20 select-none animate-pulse;
+ }
+
+ .skeleton-bg {
+ @apply bg-foreground/10;
+ }
+
+ .skeleton-div {
+ @apply bg-foreground/20 animate-pulse;
+ }
+}
+
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+@keyframes dot-pulse {
+ 0%,
+ 60%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-3px);
+ }
+}
+
+@keyframes message-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes thinking-dot {
+ 0%,
+ 60%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-3px);
+ }
+}
+
+@keyframes glow-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 oklch(0.55 0.12 250 / 0%);
+ }
+ 50% {
+ box-shadow: 0 0 0 3px oklch(0.55 0.12 250 / 8%);
+ }
+}
+
+@keyframes subtle-lift {
+ from {
+ transform: translateY(0);
+ box-shadow: var(--shadow-card);
+ }
+ to {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-float);
+ }
+}
+
+@utility fade-up {
+ animation: fade-up 0.25s var(--ease-spring) both;
+}
+
+@utility fade-in {
+ animation: fade-in 0.2s ease both;
+}
+
+@utility shimmer {
+ animation: shimmer 2s linear infinite;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ oklch(1 0 0 / 0.04),
+ transparent
+ );
+ background-size: 200% 100%;
+}
+
+@utility dot-pulse {
+ animation: dot-pulse 1.4s ease-in-out infinite;
+}
+
+@utility message-fade-in {
+ animation: message-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@utility thinking-dot {
+ animation: thinking-dot 1.4s ease-in-out infinite;
+}
+
+@utility composer-glow {
+ animation: glow-pulse 2s ease-in-out infinite;
+}
+
+.ProseMirror {
+ outline: none;
+}
+
+.cm-editor {
+ @apply bg-transparent! outline-hidden! text-[13px]! leading-[1.6]!;
+ font-family:
+ "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", ui-monospace,
+ monospace !important;
+}
+
+.cm-gutters {
+ @apply bg-transparent! border-r-0! outline-hidden!;
+}
+
+.cm-gutter.cm-lineNumbers {
+ @apply min-w-[3rem] text-muted-foreground/40 text-[11px]!;
+}
+
+.cm-scroller {
+ @apply overflow-auto!;
+}
+
+.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
+.ͼo.cm-selectionBackground,
+.ͼo.cm-content::selection {
+ background: oklch(0.55 0.12 250 / 0.15) !important;
+}
+
+.dark
+ .ͼo.cm-focused
+ > .cm-scroller
+ > .cm-selectionLayer
+ .cm-selectionBackground,
+.dark .ͼo.cm-selectionBackground,
+.dark .ͼo.cm-content::selection {
+ background: oklch(0.55 0.12 250 / 0.2) !important;
+}
+
+.cm-activeLine {
+ @apply bg-muted/50! rounded-sm!;
+}
+
+.cm-activeLineGutter {
+ @apply bg-transparent!;
+}
+
+.cm-activeLineGutter .cm-gutterElement {
+ color: var(--foreground) !important;
+ opacity: 0.7;
+}
+
+.cm-gutter.cm-lineNumbers .cm-gutterElement {
+ padding-right: 12px !important;
+}
+
+.cm-foldGutter {
+ @apply min-w-3;
+}
+
+.cm-cursor {
+ border-left-color: oklch(0.55 0.12 250) !important;
+ border-left-width: 2px !important;
+}
+
+.cm-matchingBracket {
+ background: oklch(0.55 0.12 250 / 0.12) !important;
+ outline: 1px solid oklch(0.55 0.12 250 / 0.3);
+ border-radius: 2px;
+}
+
+.suggestion-highlight {
+ @apply cursor-pointer rounded-sm bg-blue-200 transition-colors hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+@layer base {
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: oklch(0 0 0 / 0.12) transparent;
+ }
+
+ .dark * {
+ scrollbar-color: oklch(1 0 0 / 0.1) transparent;
+ }
+
+ *::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ }
+
+ *::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ *::-webkit-scrollbar-thumb {
+ background: oklch(0 0 0 / 0.12);
+ border-radius: 9999px;
+ }
+
+ *::-webkit-scrollbar-thumb:hover {
+ background: oklch(0 0 0 / 0.25);
+ }
+
+ .dark *::-webkit-scrollbar-thumb {
+ background: oklch(1 0 0 / 0.1);
+ }
+
+ .dark *::-webkit-scrollbar-thumb:hover {
+ background: oklch(1 0 0 / 0.2);
+ }
+
+ *::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+}
+
+[data-testid="artifact"] {
+ isolation: isolate;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/layout.tsx b/examples/chatbot-with-billing-with-clerk/app/layout.tsx
new file mode 100644
index 00000000..60e062f0
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/layout.tsx
@@ -0,0 +1,101 @@
+import { ClerkProvider } from '@clerk/nextjs';
+import type { Metadata } from 'next';
+import { Geist, Geist_Mono } from 'next/font/google';
+import { Suspense } from 'react';
+import { ThemeProvider } from '@/components/theme-provider';
+import { TooltipProvider } from '@/components/ui/tooltip';
+
+import './globals.css';
+
+export const metadata: Metadata = {
+ metadataBase: new URL('https://chat.vercel.ai'),
+ title: 'Next.js Chatbot Template',
+ description: 'Next.js chatbot template using the AI SDK.',
+};
+
+export const viewport = {
+ maximumScale: 1,
+};
+
+const geist = Geist({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-geist',
+});
+
+const geistMono = Geist_Mono({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-geist-mono',
+});
+
+const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)';
+const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)';
+const THEME_COLOR_SCRIPT = `\
+(function() {
+ var html = document.documentElement;
+ var meta = document.querySelector('meta[name="theme-color"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.setAttribute('name', 'theme-color');
+ document.head.appendChild(meta);
+ }
+ function updateThemeColor() {
+ var isDark = html.classList.contains('dark');
+ meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
+ }
+ var observer = new MutationObserver(updateThemeColor);
+ observer.observe(html, { attributes: true, attributeFilter: ['class'] });
+ updateThemeColor();
+})();`;
+
+function Providers({ children }: { children: React.ReactNode }) {
+ const inner = (
+
+ {children}
+
+ );
+
+ if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
+ return inner;
+ }
+
+ return (
+
+ {inner}
+
+ );
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/onboarding/page.tsx b/examples/chatbot-with-billing-with-clerk/app/onboarding/page.tsx
new file mode 100644
index 00000000..d619b5f8
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/onboarding/page.tsx
@@ -0,0 +1,26 @@
+import { Suspense } from 'react';
+import { redirect } from 'next/navigation';
+import { auth, currentUser } from '@clerk/nextjs/server';
+import { createPolarCustomer } from '@/lib/polar-client';
+
+async function OnboardingContent() {
+ const { userId } = await auth();
+ if (!userId) redirect('/sign-up');
+
+ const user = await currentUser();
+ const email = user?.primaryEmailAddress?.emailAddress;
+ if (email) {
+ await createPolarCustomer(email, userId);
+ }
+
+ redirect('/');
+ return null;
+}
+
+export default function OnboardingPage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/sign-in/[[...sign-in]]/page.tsx b/examples/chatbot-with-billing-with-clerk/app/sign-in/[[...sign-in]]/page.tsx
new file mode 100644
index 00000000..09127654
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/sign-in/[[...sign-in]]/page.tsx
@@ -0,0 +1,9 @@
+import { SignIn } from '@clerk/nextjs';
+
+export default function SignInPage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/app/sign-up/[[...sign-up]]/page.tsx b/examples/chatbot-with-billing-with-clerk/app/sign-up/[[...sign-up]]/page.tsx
new file mode 100644
index 00000000..52aee419
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/app/sign-up/[[...sign-up]]/page.tsx
@@ -0,0 +1,9 @@
+import { SignUp } from '@clerk/nextjs';
+
+export default function SignUpPage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components.json b/examples/chatbot-with-billing-with-clerk/components.json
new file mode 100644
index 00000000..912659ac
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "radix-maia",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/app-sidebar.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/app-sidebar.tsx
new file mode 100644
index 00000000..51fde1cd
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/app-sidebar.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import {
+ GaugeIcon,
+ LogInIcon,
+ MessageSquareIcon,
+ PanelLeftIcon,
+ PenSquareIcon,
+ TrashIcon,
+} from 'lucide-react';
+import Link from 'next/link';
+import { usePathname, useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useSWRConfig } from 'swr';
+import { unstable_serialize } from 'swr/infinite';
+import {
+ getChatHistoryPaginationKey,
+ SidebarHistory,
+} from '@/components/chat/sidebar-history';
+import { SignInDialog } from '@/components/chat/sign-in-button';
+import { SidebarUserNav } from '@/components/chat/sidebar-user-nav';
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+ SidebarTrigger,
+ useSidebar,
+} from '@/components/ui/sidebar';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '../ui/alert-dialog';
+import { Button } from '../ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+
+export function AppSidebar({ userId }: { userId: string | null }) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const { setOpenMobile, toggleSidebar } = useSidebar();
+ const { mutate } = useSWRConfig();
+ const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
+ const [signInOpen, setSignInOpen] = useState(false);
+
+ const handleNewChat = () => {
+ if (!userId) {
+ setSignInOpen(true);
+ return;
+ }
+ setOpenMobile(false);
+ router.push('/chat');
+ };
+
+ const handleDeleteAll = () => {
+ setShowDeleteAllDialog(false);
+ router.replace('/');
+ mutate(unstable_serialize(getChatHistoryPaginationKey), [], {
+ revalidate: false,
+ });
+
+ fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/history`, {
+ method: 'DELETE',
+ });
+
+ toast.success('All chats deleted');
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ setOpenMobile(false)}>
+
+
+
+
+
+ toggleSidebar()}
+ >
+
+
+
+
+ Open sidebar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setOpenMobile(false);
+ router.push('/');
+ }}
+ >
+
+ Usage
+
+
+
+
+
+ New chat
+
+
+ {userId && (
+
+ setShowDeleteAllDialog(true)}
+ tooltip="Delete All Chats"
+ >
+
+ Delete all
+
+
+ )}
+
+
+
+
+
+
+ {userId ? (
+
+ ) : (
+
+
+ Sign in
+
+ }
+ />
+ )}
+
+
+
+
+
+
+
+ Delete all chats?
+
+ This action cannot be undone. This will permanently delete all
+ your chats and remove them from our servers.
+
+
+
+ Cancel
+
+ Delete All
+
+
+
+
+ >
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/chat-header.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/chat-header.tsx
new file mode 100644
index 00000000..fc2eb180
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/chat-header.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import { PanelLeftIcon } from 'lucide-react';
+import Link from 'next/link';
+import { memo } from 'react';
+import { Button } from '@/components/ui/button';
+import { useSidebar } from '@/components/ui/sidebar';
+import { VercelIcon } from './icons';
+import { VisibilitySelector, type VisibilityType } from './visibility-selector';
+
+function PureChatHeader({
+ chatId,
+ selectedVisibilityType,
+ isReadonly,
+}: {
+ chatId: string;
+ selectedVisibilityType: VisibilityType;
+ isReadonly: boolean;
+}) {
+ const { state, toggleSidebar, isMobile } = useSidebar();
+
+ if (state === 'collapsed' && !isMobile) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {!isReadonly && (
+
+ )}
+
+
+
+
+ Deploy with Vercel
+
+
+
+ );
+}
+
+export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
+ return (
+ prevProps.chatId === nextProps.chatId &&
+ prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
+ prevProps.isReadonly === nextProps.isReadonly
+ );
+});
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/icons.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/icons.tsx
new file mode 100644
index 00000000..0d9d5af6
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/icons.tsx
@@ -0,0 +1,1213 @@
+export const BotIcon = () => {
+ return (
+
+
+
+ );
+};
+
+export const UserIcon = () => {
+ return (
+
+
+
+ );
+};
+
+export const AttachmentIcon = () => {
+ return (
+
+
+
+ );
+};
+
+export const VercelIcon = ({ size = 17 }) => {
+ return (
+
+
+
+ );
+};
+
+export const GitIcon = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const BoxIcon = ({ size = 16 }: { size: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const HomeIcon = ({ size = 16 }: { size: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const GPSIcon = ({ size = 16 }: { size: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const InvoiceIcon = ({ size = 16 }: { size: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const LogoOpenAI = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const LogoGoogle = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const LogoAnthropic = () => {
+ return (
+
+
+
+ );
+};
+
+export const RouteIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const FileIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const LoaderIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const UploadIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const MenuIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const PencilEditIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const CheckedSquare = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const UncheckedSquare = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const MoreIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const TrashIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const InfoIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const ArrowUpIcon = ({
+ size = 16,
+ ...props
+}: { size?: number } & React.SVGProps) => {
+ return (
+
+
+
+ );
+};
+
+export const StopIcon = ({
+ size = 16,
+ ...props
+}: { size?: number } & React.SVGProps) => {
+ return (
+
+
+
+ );
+};
+
+export const PaperclipIcon = ({
+ size = 16,
+ ...props
+}: { size?: number } & React.SVGProps) => {
+ return (
+
+
+
+ );
+};
+
+export const MoreHorizontalIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const MessageIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const CrossIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const CrossSmallIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const UndoIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const RedoIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const DeltaIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const CpuIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const PenIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const SummarizeIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const SidebarLeftIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const PlusIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const CopyIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const ThumbUpIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const ThumbDownIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const SparklesIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+
+
+);
+
+export const CheckCircleFillIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const GlobeIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const LockIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const EyeIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const ShareIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const CodeIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const PlayIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const PythonIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+
+ );
+};
+
+export const TerminalWindowIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const TerminalIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const ClockRewind = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const LogsIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const ImageIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
+
+export const FullscreenIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const DownloadIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const LineChartIcon = ({ size = 16 }: { size?: number }) => (
+
+
+
+);
+
+export const WarningIcon = ({ size = 16 }: { size?: number }) => {
+ return (
+
+
+
+ );
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/preview.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/preview.tsx
new file mode 100644
index 00000000..9d55153d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/preview.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { suggestions } from '@/lib/constants';
+import { SparklesIcon } from './icons';
+
+export function Preview() {
+ const router = useRouter();
+
+ const handleAction = (query?: string) => {
+ const url = query ? `/?query=${encodeURIComponent(query)}` : '/';
+ router.push(url);
+ };
+
+ return (
+
+
+
+
+
+
+ What can I help with?
+
+
+ Ask a question, write code, or explore ideas.
+
+
+
+
+ {suggestions.map(suggestion => (
+ handleAction(suggestion)}
+ type="button"
+ >
+ {suggestion}
+
+ ))}
+
+
+
+
+ handleAction()}
+ type="button"
+ >
+ Ask anything...
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/shell.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/shell.tsx
new file mode 100644
index 00000000..f2f16e8c
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/shell.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useRef } from 'react';
+import type { UIMessage } from 'ai';
+import { useChat } from '@ai-billing/nextjs';
+import { ChatInput, ChatMessages } from '@ai-billing/nextjs';
+import {
+ saveChat,
+ saveMessages,
+ updateChatTitleById,
+} from '@/app/(chat)/db-actions';
+import { generateTitleFromUserMessage } from '@/app/(chat)/actions';
+import { ChatHeader } from './chat-header';
+
+interface ChatShellProps {
+ userId: string;
+ chatId: string;
+ initialMessages?: UIMessage[];
+ isReadonly?: boolean;
+}
+
+export function ChatShell({
+ userId,
+ chatId,
+ initialMessages,
+ isReadonly,
+}: ChatShellProps) {
+ const router = useRouter();
+ const isNewChat = useRef(!initialMessages?.length);
+
+ const {
+ messages,
+ status,
+ submit,
+ stop,
+ selectedModel,
+ onModelSelect,
+ costs,
+ errors,
+ } = useChat({
+ userId,
+ initialMessages,
+ tags: { chatId, userId },
+ onSubmit: async userMsg => {
+ if (!isNewChat.current) return;
+ isNewChat.current = false;
+
+ await saveChat({
+ id: chatId,
+ userId,
+ title: 'New chat',
+ visibility: 'private',
+ });
+ await saveMessages({
+ messages: [
+ {
+ id: userMsg.id,
+ chatId,
+ role: userMsg.role,
+ parts: userMsg.parts as never,
+ attachments: [],
+ createdAt: new Date(),
+ },
+ ],
+ });
+
+ generateTitleFromUserMessage({ message: userMsg })
+ .then(title => updateChatTitleById({ chatId, title }))
+ .catch(() => {});
+ },
+ onFinish: async allMessages => {
+ const lastMsg = allMessages.findLast(m => m.role === 'assistant');
+ if (!lastMsg) return;
+
+ await saveMessages({
+ messages: [
+ {
+ id: lastMsg.id,
+ chatId,
+ role: lastMsg.role,
+ parts: lastMsg.parts as never,
+ attachments: [],
+ createdAt: new Date(),
+ },
+ ],
+ });
+
+ router.refresh();
+ },
+ });
+
+ return (
+
+
+
+
+
+ {!isReadonly && (
+
+ )}
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history-item.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history-item.tsx
new file mode 100644
index 00000000..e6b13bd4
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history-item.tsx
@@ -0,0 +1,124 @@
+import Link from 'next/link';
+import { memo } from 'react';
+import { useChatVisibility } from '@/hooks/use-chat-visibility';
+import type { Chat } from '@/lib/db/schema';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '../ui/dropdown-menu';
+import {
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '../ui/sidebar';
+import {
+ CheckCircleFillIcon,
+ GlobeIcon,
+ LockIcon,
+ MoreHorizontalIcon,
+ ShareIcon,
+ TrashIcon,
+} from './icons';
+
+const PureChatItem = ({
+ chat,
+ isActive,
+ onDelete,
+ setOpenMobile,
+}: {
+ chat: Chat;
+ isActive: boolean;
+ onDelete: (chatId: string) => void;
+ setOpenMobile: (open: boolean) => void;
+}) => {
+ const { visibilityType, setVisibilityType } = useChatVisibility({
+ chatId: chat.id,
+ initialVisibilityType: chat.visibility,
+ });
+
+ return (
+
+
+ setOpenMobile(false)}>
+ {chat.title}
+
+
+
+
+
+
+
+ More
+
+
+
+
+
+
+
+ Share
+
+
+
+ {
+ setVisibilityType('private');
+ }}
+ >
+
+
+ Private
+
+ {visibilityType === 'private' ? (
+
+ ) : null}
+
+ {
+ setVisibilityType('public');
+ }}
+ >
+
+
+ Public
+
+ {visibilityType === 'public' ? : null}
+
+
+
+
+
+ onDelete(chat.id)}
+ variant="destructive"
+ >
+
+ Delete
+
+
+
+
+ );
+};
+
+export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
+ if (prevProps.isActive !== nextProps.isActive) {
+ return false;
+ }
+ return true;
+});
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history.tsx
new file mode 100644
index 00000000..be44c978
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-history.tsx
@@ -0,0 +1,372 @@
+'use client';
+
+import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns';
+import { motion } from 'framer-motion';
+import { usePathname, useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import useSWRInfinite from 'swr/infinite';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ useSidebar,
+} from '@/components/ui/sidebar';
+import type { Chat } from '@/lib/db/schema';
+import { fetcher } from '@/lib/utils';
+import { LoaderIcon } from './icons';
+import { ChatItem } from './sidebar-history-item';
+
+type GroupedChats = {
+ today: Chat[];
+ yesterday: Chat[];
+ lastWeek: Chat[];
+ lastMonth: Chat[];
+ older: Chat[];
+};
+
+export type ChatHistory = {
+ chats: Chat[];
+ hasMore: boolean;
+};
+
+const PAGE_SIZE = 20;
+
+const groupChatsByDate = (chats: Chat[]): GroupedChats => {
+ const now = new Date();
+ const oneWeekAgo = subWeeks(now, 1);
+ const oneMonthAgo = subMonths(now, 1);
+
+ return chats.reduce(
+ (groups, chat) => {
+ const chatDate = new Date(chat.createdAt);
+
+ if (isToday(chatDate)) {
+ groups.today.push(chat);
+ } else if (isYesterday(chatDate)) {
+ groups.yesterday.push(chat);
+ } else if (chatDate > oneWeekAgo) {
+ groups.lastWeek.push(chat);
+ } else if (chatDate > oneMonthAgo) {
+ groups.lastMonth.push(chat);
+ } else {
+ groups.older.push(chat);
+ }
+
+ return groups;
+ },
+ {
+ today: [],
+ yesterday: [],
+ lastWeek: [],
+ lastMonth: [],
+ older: [],
+ } as GroupedChats,
+ );
+};
+
+export function getChatHistoryPaginationKey(
+ pageIndex: number,
+ previousPageData: ChatHistory,
+) {
+ if (previousPageData && previousPageData.hasMore === false) {
+ return null;
+ }
+
+ if (pageIndex === 0) {
+ return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/history?limit=${PAGE_SIZE}`;
+ }
+
+ const firstChatFromPage = previousPageData.chats.at(-1);
+
+ if (!firstChatFromPage) {
+ return null;
+ }
+
+ return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
+}
+
+export function SidebarHistory({ userId }: { userId: string | null }) {
+ const { setOpenMobile } = useSidebar();
+ const pathname = usePathname();
+ const id = pathname?.startsWith('/chat/') ? pathname.split('/')[2] : null;
+
+ const {
+ data: paginatedChatHistories,
+ setSize,
+ isValidating,
+ isLoading,
+ mutate,
+ } = useSWRInfinite(
+ userId ? getChatHistoryPaginationKey : () => null,
+ fetcher,
+ { fallbackData: [], revalidateOnFocus: false },
+ );
+
+ const router = useRouter();
+ const [deleteId, setDeleteId] = useState(null);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+
+ const hasReachedEnd = paginatedChatHistories
+ ? paginatedChatHistories.some(page => page.hasMore === false)
+ : false;
+
+ const hasEmptyChatHistory = paginatedChatHistories
+ ? paginatedChatHistories.every(page => page.chats.length === 0)
+ : false;
+
+ const handleDelete = () => {
+ const chatToDelete = deleteId;
+ const isCurrentChat = pathname === `/chat/${chatToDelete}`;
+
+ setShowDeleteDialog(false);
+
+ if (isCurrentChat) {
+ router.replace('/');
+ }
+
+ mutate(chatHistories => {
+ if (chatHistories) {
+ return chatHistories.map(chatHistory => ({
+ ...chatHistory,
+ chats: chatHistory.chats.filter(chat => chat.id !== chatToDelete),
+ }));
+ }
+ });
+
+ fetch(
+ `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/chat?id=${chatToDelete}`,
+ { method: 'DELETE' },
+ );
+
+ toast.success('Chat deleted');
+ };
+
+ if (!userId) {
+ return (
+
+
+
+ Login to save and revisit previous chats!
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+ History
+
+
+
+ {[44, 32, 28, 64, 52].map(item => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (hasEmptyChatHistory) {
+ return (
+
+
+ History
+
+
+
+ Your conversations will appear here once you start chatting!
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ History
+
+
+
+ {paginatedChatHistories &&
+ (() => {
+ const chatsFromHistory = paginatedChatHistories.flatMap(
+ paginatedChatHistory => paginatedChatHistory.chats,
+ );
+
+ const groupedChats = groupChatsByDate(chatsFromHistory);
+
+ return (
+
+ {groupedChats.today.length > 0 && (
+
+
+ Today
+
+ {groupedChats.today.map(chat => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.yesterday.length > 0 && (
+
+
+ Yesterday
+
+ {groupedChats.yesterday.map(chat => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.lastWeek.length > 0 && (
+
+
+ Last 7 days
+
+ {groupedChats.lastWeek.map(chat => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.lastMonth.length > 0 && (
+
+
+ Last 30 days
+
+ {groupedChats.lastMonth.map(chat => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.older.length > 0 && (
+
+
+ Older
+
+ {groupedChats.older.map(chat => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ );
+ })()}
+
+
+ {
+ if (!isValidating && !hasReachedEnd) {
+ setSize(size => size + 1);
+ }
+ }}
+ />
+
+ {hasReachedEnd ? null : (
+
+ )}
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete your
+ chat and remove it from our servers.
+
+
+
+ Cancel
+
+ Continue
+
+
+
+
+ >
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-user-nav.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-user-nav.tsx
new file mode 100644
index 00000000..ef265e6b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/sidebar-user-nav.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { UserButton } from '@clerk/nextjs';
+import { SidebarMenu, SidebarMenuItem } from '@/components/ui/sidebar';
+
+export function SidebarUserNav() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/sign-in-button.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/sign-in-button.tsx
new file mode 100644
index 00000000..4fba8b5e
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/sign-in-button.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { SignIn, useUser } from '@clerk/nextjs';
+import { useRouter } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+
+export function SignInDialog({
+ open,
+ onOpenChange,
+ trigger,
+}: {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ trigger?: React.ReactNode;
+}) {
+ const { isSignedIn } = useUser();
+ const router = useRouter();
+ const [internalOpen, setInternalOpen] = useState(false);
+ const isControlled = open !== undefined && onOpenChange !== undefined;
+ const isOpen = isControlled ? open : internalOpen;
+ const setOpen = isControlled ? onOpenChange : setInternalOpen;
+
+ useEffect(() => {
+ if (isSignedIn && isOpen) {
+ setOpen(false);
+ router.refresh();
+ }
+ }, [isSignedIn, isOpen, setOpen, router]);
+
+ return (
+
+ {trigger && {trigger} }
+
+ Sign in
+
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/submit-button.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/submit-button.tsx
new file mode 100644
index 00000000..417e9b75
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/submit-button.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { useFormStatus } from 'react-dom';
+
+import { LoaderIcon } from '@/components/chat/icons';
+
+import { Button } from '../ui/button';
+
+export function SubmitButton({
+ children,
+ isSuccessful,
+}: {
+ children: React.ReactNode;
+ isSuccessful: boolean;
+}) {
+ const { pending } = useFormStatus();
+
+ return (
+
+ {children}
+
+ {(pending || isSuccessful) && (
+
+
+
+ )}
+
+
+ {pending || isSuccessful ? 'Loading' : 'Submit form'}
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/toast.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/toast.tsx
new file mode 100644
index 00000000..0e787ee6
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/toast.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { type ReactNode, useEffect, useRef, useState } from 'react';
+import { toast as sonnerToast } from 'sonner';
+import { cn } from '@/lib/utils';
+import { CheckCircleFillIcon, WarningIcon } from './icons';
+
+const iconsByType: Record<'success' | 'error', ReactNode> = {
+ success: ,
+ error: ,
+};
+
+export function toast(props: Omit) {
+ return sonnerToast.custom(id => (
+
+ ));
+}
+
+function Toast(props: ToastProps) {
+ const { id, type, description } = props;
+
+ const descriptionRef = useRef(null);
+ const [multiLine, setMultiLine] = useState(false);
+
+ useEffect(() => {
+ const el = descriptionRef.current;
+ if (!el) {
+ return;
+ }
+
+ const update = () => {
+ const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
+ const lines = Math.round(el.scrollHeight / lineHeight);
+ setMultiLine(lines > 1);
+ };
+
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+
+ return () => ro.disconnect();
+ }, []);
+
+ return (
+
+
+
+ {iconsByType[type]}
+
+
+ {description}
+
+
+
+ );
+}
+
+type ToastProps = {
+ id: string | number;
+ type: 'success' | 'error';
+ description: string;
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/chat/visibility-selector.tsx b/examples/chatbot-with-billing-with-clerk/components/chat/visibility-selector.tsx
new file mode 100644
index 00000000..7510a229
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/chat/visibility-selector.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { type ReactNode, useMemo, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useChatVisibility } from '@/hooks/use-chat-visibility';
+import { cn } from '@/lib/utils';
+import {
+ CheckCircleFillIcon,
+ ChevronDownIcon,
+ GlobeIcon,
+ LockIcon,
+} from './icons';
+
+export type VisibilityType = 'private' | 'public';
+
+const visibilities: Array<{
+ id: VisibilityType;
+ label: string;
+ description: string;
+ icon: ReactNode;
+}> = [
+ {
+ id: 'private',
+ label: 'Private',
+ description: 'Only you can access this chat',
+ icon: ,
+ },
+ {
+ id: 'public',
+ label: 'Public',
+ description: 'Anyone with the link can access this chat',
+ icon: ,
+ },
+];
+
+export function VisibilitySelector({
+ chatId,
+ className,
+ selectedVisibilityType,
+}: {
+ chatId: string;
+ selectedVisibilityType: VisibilityType;
+} & React.ComponentProps) {
+ const [open, setOpen] = useState(false);
+
+ const { visibilityType, setVisibilityType } = useChatVisibility({
+ chatId,
+ initialVisibilityType: selectedVisibilityType,
+ });
+
+ const selectedVisibility = useMemo(
+ () => visibilities.find(visibility => visibility.id === visibilityType),
+ [visibilityType],
+ );
+
+ return (
+
+
+
+ {selectedVisibility?.icon}
+ {selectedVisibility?.label}
+
+
+
+
+
+ {visibilities.map(visibility => (
+ {
+ setVisibilityType(visibility.id);
+ setOpen(false);
+ }}
+ >
+
+ {visibility.label}
+ {visibility.description && (
+
+ {visibility.description}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/theme-provider.tsx b/examples/chatbot-with-billing-with-clerk/components/theme-provider.tsx
new file mode 100644
index 00000000..430a5a9d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/theme-provider.tsx
@@ -0,0 +1,8 @@
+'use client';
+
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+import type { ThemeProviderProps } from 'next-themes';
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children} ;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/alert-dialog.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..9543909b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/alert-dialog.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import * as React from 'react';
+import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = 'default',
+ ...props
+}: React.ComponentProps & {
+ size?: 'default' | 'sm';
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ variant = 'default',
+ size = 'default',
+ ...props
+}: React.ComponentProps &
+ Pick, 'variant' | 'size'>) {
+ return (
+
+
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = 'outline',
+ size = 'default',
+ ...props
+}: React.ComponentProps &
+ Pick, 'variant' | 'size'>) {
+ return (
+
+
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/badge.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/badge.tsx
new file mode 100644
index 00000000..ef0eb16d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/badge.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+const badgeVariants = cva(
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary:
+ 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+ destructive:
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
+ outline:
+ 'border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : 'span';
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/button-group.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/button-group.tsx
new file mode 100644
index 00000000..9fd27ca2
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { Separator } from '@/components/ui/separator';
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl!',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl!',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : 'div';
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/button.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/button.tsx
new file mode 100644
index 00000000..41fabee3
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/button.tsx
@@ -0,0 +1,65 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
+ outline:
+ 'border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
+ destructive:
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default:
+ 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
+ xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
+ sm: 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ lg: 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
+ icon: 'size-9',
+ 'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = 'default',
+ size = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot.Root : 'button';
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/collapsible.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/collapsible.tsx
new file mode 100644
index 00000000..247f1ead
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/command.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/command.tsx
new file mode 100644
index 00000000..cb164c36
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/command.tsx
@@ -0,0 +1,191 @@
+'use client';
+
+import * as React from 'react';
+import { Command as CommandPrimitive } from 'cmdk';
+
+import { cn } from '@/lib/utils';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { InputGroup, InputGroupAddon } from '@/components/ui/input-group';
+import { SearchIcon } from 'lucide-react';
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandDialog({
+ title = 'Command Palette',
+ description = 'Search for a command to run...',
+ children,
+ className,
+ showCloseButton = false,
+ ...props
+}: React.ComponentProps & {
+ title?: string;
+ description?: string;
+ className?: string;
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {children}
+
+
+ );
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandEmpty({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/dialog.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/dialog.tsx
new file mode 100644
index 00000000..5c4a5d36
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/dialog.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import * as React from 'react';
+import { Dialog as DialogPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { XIcon } from 'lucide-react';
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/dropdown-menu.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..b86e120c
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/dropdown-menu.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import * as React from 'react';
+import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { CheckIcon, ChevronRightIcon } from 'lucide-react';
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ align = 'start',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = 'default',
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/hover-card.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/hover-card.tsx
new file mode 100644
index 00000000..5eb914b1
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/hover-card.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import * as React from 'react';
+import { HoverCard as HoverCardPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function HoverCard({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function HoverCardTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function HoverCardContent({
+ className,
+ align = 'center',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/input-group.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/input-group.tsx
new file mode 100644
index 00000000..de69b9a0
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/input-group.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+
+function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+ [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ 'inline-start':
+ 'order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]',
+ 'inline-end':
+ 'order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]',
+ 'block-start':
+ 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3',
+ 'block-end':
+ 'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3',
+ },
+ },
+ defaultVariants: {
+ align: 'inline-start',
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = 'inline-start',
+ ...props
+}: React.ComponentProps<'div'> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest('button')) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector('input')?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva(
+ 'flex items-center gap-2 rounded-4xl text-sm shadow-none',
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: '',
+ 'icon-xs': 'size-6 p-0 has-[>svg]:p-0',
+ 'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
+ },
+ },
+ defaultVariants: {
+ size: 'xs',
+ },
+ },
+);
+
+function InputGroupButton({
+ className,
+ type = 'button',
+ variant = 'ghost',
+ size = 'xs',
+ ...props
+}: Omit
, 'size'> &
+ VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<'textarea'>) {
+ return (
+
+ );
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/input.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/input.tsx
new file mode 100644
index 00000000..52fec3fc
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/label.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/label.tsx
new file mode 100644
index 00000000..c1f6f148
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/label.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import * as React from 'react';
+import { Label as LabelPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/popover.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/popover.tsx
new file mode 100644
index 00000000..712b894c
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/popover.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { Popover } from 'radix-ui';
+import { cn } from '@/lib/utils';
+
+function PopoverRoot({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = 'center',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ PopoverRoot as Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverAnchor,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/scroll-area.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..3acac77c
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import * as React from 'react';
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/select.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/select.tsx
new file mode 100644
index 00000000..ad471792
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/select.tsx
@@ -0,0 +1,198 @@
+'use client';
+
+import * as React from 'react';
+import { Select as SelectPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = 'default',
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: 'sm' | 'default';
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = 'item-aligned',
+ align = 'center',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/separator.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/separator.tsx
new file mode 100644
index 00000000..f3cf8810
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import * as React from 'react';
+import { Separator as SeparatorPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/sheet.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/sheet.tsx
new file mode 100644
index 00000000..22ba9b18
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/sheet.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import * as React from 'react';
+import { Dialog as SheetPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { XIcon } from 'lucide-react';
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetContent({
+ className,
+ children,
+ side = 'right',
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: 'top' | 'right' | 'bottom' | 'left';
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/sidebar.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/sidebar.tsx
new file mode 100644
index 00000000..4098b545
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/sidebar.tsx
@@ -0,0 +1,717 @@
+'use client';
+
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Slot } from 'radix-ui';
+
+import { useIsMobile } from '@/hooks/use-mobile';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Separator } from '@/components/ui/separator';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { PanelLeftIcon } from 'lucide-react';
+
+const SIDEBAR_COOKIE_NAME = 'sidebar_state';
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = '16rem';
+const SIDEBAR_WIDTH_MOBILE = '18rem';
+const SIDEBAR_WIDTH_ICON = '3rem';
+const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+
+type SidebarContextProps = {
+ state: 'expanded' | 'collapsed';
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error('useSidebar must be used within a SidebarProvider.');
+ }
+
+ return context;
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === 'function' ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open],
+ );
+
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [toggleSidebar]);
+
+ const state = open ? 'expanded' : 'collapsed';
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function Sidebar({
+ side = 'left',
+ variant = 'sidebar',
+ collapsible = 'offcanvas',
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<'div'> & {
+ side?: 'left' | 'right';
+ variant?: 'sidebar' | 'floating' | 'inset';
+ collapsible?: 'offcanvas' | 'icon' | 'none';
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === 'none') {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+ {
+ onClick?.(event);
+ toggleSidebar();
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ );
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
+ const { toggleSidebar, state } = useSidebar();
+ const isCollapsed = state === 'collapsed';
+
+ return (
+
+ );
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
+ return (
+
+ );
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : 'div';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
+ return (
+
+ );
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ );
+}
+
+const sidebarMenuButtonVariants = cva(
+ 'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md px-2.5 text-left text-[13px] text-sidebar-foreground/70 outline-hidden transition-colors duration-150 group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
+ {
+ variants: {
+ variant: {
+ default: 'hover:text-sidebar-accent-foreground',
+ outline:
+ 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
+ },
+ size: {
+ default: 'h-8 text-[13px]',
+ sm: 'h-8 text-xs',
+ lg: 'h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = 'default',
+ size = 'default',
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+} & VariantProps) {
+ const Comp = asChild ? Slot.Root : 'button';
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === 'string') {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ showOnHover &&
+ 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ showIcon?: boolean;
+}) {
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ });
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = 'md',
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean;
+ size?: 'sm' | 'md';
+ isActive?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : 'a';
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/skeleton.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/skeleton.tsx
new file mode 100644
index 00000000..41158659
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/spinner.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/spinner.tsx
new file mode 100644
index 00000000..a6a4dea2
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/spinner.tsx
@@ -0,0 +1,15 @@
+import { cn } from '@/lib/utils';
+import { Loader2Icon } from 'lucide-react';
+
+function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
+
+export { Spinner };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/textarea.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/textarea.tsx
new file mode 100644
index 00000000..c0fd1118
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/examples/chatbot-with-billing-with-clerk/components/ui/tooltip.tsx b/examples/chatbot-with-billing-with-clerk/components/ui/tooltip.tsx
new file mode 100644
index 00000000..f09c1909
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import * as React from 'react';
+import { Tooltip as TooltipPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/examples/chatbot-with-billing-with-clerk/components/usage/usage-content.tsx b/examples/chatbot-with-billing-with-clerk/components/usage/usage-content.tsx
new file mode 100644
index 00000000..3dd5505b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/components/usage/usage-content.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { PanelLeftIcon } from 'lucide-react';
+import Link from 'next/link';
+import { Button } from '@/components/ui/button';
+import { useSidebar } from '@/components/ui/sidebar';
+import { VercelIcon } from '@/components/chat/icons';
+import { CreditUsagePolar, CreditTopUpPolar } from '@ai-billing/nextjs';
+
+export function UsageContent({
+ userId,
+ isAnonymous,
+}: {
+ userId?: string;
+ isAnonymous?: boolean;
+}) {
+ const { state, toggleSidebar, isMobile } = useSidebar();
+ const effectiveUserId = isAnonymous ? 'anonymous_user' : userId;
+
+ return (
+ <>
+ {(state !== 'collapsed' || isMobile) && (
+
+
+
+
+
+
+
+
+
+
+
+
+ Deploy with Vercel
+
+
+
+ )}
+
+
+
Usage & Billing
+
+ Track your usage and top up your credits.
+
+
+ {effectiveUserId && (
+ <>
+
+
+ >
+ )}
+ {!effectiveUserId && (
+
+
+ Sign in to view your usage and billing information.
+
+
+ )}
+
+ >
+ );
+}
diff --git a/examples/chatbot-with-billing-with-clerk/drizzle.config.ts b/examples/chatbot-with-billing-with-clerk/drizzle.config.ts
new file mode 100644
index 00000000..636bb3c5
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/drizzle.config.ts
@@ -0,0 +1,15 @@
+import { config } from 'dotenv';
+import { defineConfig } from 'drizzle-kit';
+
+config({
+ path: '.env.local',
+});
+
+export default defineConfig({
+ schema: './lib/db/schema.ts',
+ out: './lib/db/migrations',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env.POSTGRES_URL ?? '',
+ },
+});
diff --git a/examples/chatbot-with-billing-with-clerk/hooks/use-chat-visibility.ts b/examples/chatbot-with-billing-with-clerk/hooks/use-chat-visibility.ts
new file mode 100644
index 00000000..5c62821d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/hooks/use-chat-visibility.ts
@@ -0,0 +1,55 @@
+'use client';
+
+import { useMemo } from 'react';
+import useSWR, { useSWRConfig } from 'swr';
+import { unstable_serialize } from 'swr/infinite';
+import { updateChatVisibility } from '@/app/(chat)/actions';
+import {
+ type ChatHistory,
+ getChatHistoryPaginationKey,
+} from '@/components/chat/sidebar-history';
+import type { VisibilityType } from '@/components/chat/visibility-selector';
+
+export function useChatVisibility({
+ chatId,
+ initialVisibilityType,
+}: {
+ chatId: string;
+ initialVisibilityType: VisibilityType;
+}) {
+ const { mutate, cache } = useSWRConfig();
+ const history: ChatHistory = cache.get(
+ `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/history`,
+ )?.data;
+
+ const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
+ `${chatId}-visibility`,
+ null,
+ {
+ fallbackData: initialVisibilityType,
+ },
+ );
+
+ const visibilityType = useMemo(() => {
+ if (!history) {
+ return localVisibility;
+ }
+ const chat = history.chats.find(currentChat => currentChat.id === chatId);
+ if (!chat) {
+ return 'private';
+ }
+ return chat.visibility;
+ }, [history, chatId, localVisibility]);
+
+ const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
+ setLocalVisibility(updatedVisibilityType);
+ mutate(unstable_serialize(getChatHistoryPaginationKey));
+
+ updateChatVisibility({
+ chatId,
+ visibility: updatedVisibilityType,
+ });
+ };
+
+ return { visibilityType, setVisibilityType };
+}
diff --git a/examples/chatbot-with-billing-with-clerk/hooks/use-mobile.ts b/examples/chatbot-with-billing-with-clerk/hooks/use-mobile.ts
new file mode 100644
index 00000000..821f8ff4
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/hooks/use-mobile.ts
@@ -0,0 +1,21 @@
+import * as React from 'react';
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener('change', onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener('change', onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/instrumentation-client.ts b/examples/chatbot-with-billing-with-clerk/instrumentation-client.ts
new file mode 100644
index 00000000..d29f3254
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/instrumentation-client.ts
@@ -0,0 +1,10 @@
+import { initBotId } from 'botid/client/core';
+
+initBotId({
+ protect: [
+ {
+ path: '/api/chat',
+ method: 'POST',
+ },
+ ],
+});
diff --git a/examples/chatbot-with-billing-with-clerk/instrumentation.ts b/examples/chatbot-with-billing-with-clerk/instrumentation.ts
new file mode 100644
index 00000000..c2fbe672
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/instrumentation.ts
@@ -0,0 +1,5 @@
+import { registerOTel } from '@vercel/otel';
+
+export function register() {
+ registerOTel({ serviceName: 'chatbot' });
+}
diff --git a/examples/chatbot-with-billing-with-clerk/lib/ai/chat-setup.ts b/examples/chatbot-with-billing-with-clerk/lib/ai/chat-setup.ts
new file mode 100644
index 00000000..db817332
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/ai/chat-setup.ts
@@ -0,0 +1,4 @@
+import { configureChatTools } from '@ai-billing/nextjs/server';
+import { getWeather } from './tools/get-weather';
+
+configureChatTools({ tools: { getWeather }, maxSteps: 5 });
diff --git a/examples/chatbot-with-billing-with-clerk/lib/ai/prompts.ts b/examples/chatbot-with-billing-with-clerk/lib/ai/prompts.ts
new file mode 100644
index 00000000..30b8649a
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/ai/prompts.ts
@@ -0,0 +1,119 @@
+import type { Geo } from '@vercel/functions';
+
+export type ArtifactKind = 'code' | 'text' | 'sheet' | 'image';
+
+const artifactsPrompt = `
+
+**When to use \`createDocument\`:**
+- When the user asks to write, create, or generate content (essays, stories, emails, reports)
+- When the user asks to write code, build a script, or implement an algorithm
+- You MUST specify kind: 'code' for programming, 'text' for writing, 'sheet' for data
+- Include ALL content in the createDocument call. Do not create then edit.
+
+**When NOT to use \`createDocument\`:**
+- For answering questions, explanations, or conversational responses
+- For short code snippets or examples shown inline
+- When the user asks "what is", "how does", "explain", etc.
+
+**Using \`editDocument\` (preferred for targeted changes):**
+- For scripts: fixing bugs, adding/removing lines, renaming variables, adding logs
+- For documents: fixing typos, rewording paragraphs, inserting sections
+- Uses find-and-replace: provide exact old_string and new_string
+- Include 3-5 surrounding lines in old_string to ensure a unique match
+- Use replace_all:true for renaming across the whole artifact
+- Can call multiple times for several independent edits
+
+**Using \`updateDocument\` (full rewrite only):**
+- Only when most of the content needs to change
+- When editDocument would require too many individual edits
+
+**When NOT to use \`editDocument\` or \`updateDocument\`:**
+- Immediately after creating an artifact
+- In the same response as createDocument
+- Without explicit user request to modify
+
+**After any create/edit/update:**
+- NEVER repeat, summarize, or output the artifact content in chat
+- Only respond with a short confirmation
+
+**Using \`requestSuggestions\`:**
+- ONLY when the user explicitly asks for suggestions on an existing document
+`;
+
+export const regularPrompt = `You are a helpful assistant. Keep responses concise and direct.
+
+When asked to write, create, or build something, do it immediately. Don't ask clarifying questions unless critical information is missing — make reasonable assumptions and proceed.`;
+
+export type RequestHints = {
+ latitude: Geo['latitude'];
+ longitude: Geo['longitude'];
+ city: Geo['city'];
+ country: Geo['country'];
+};
+
+export const getRequestPromptFromHints = (requestHints: RequestHints) => `\
+About the origin of user's request:
+- lat: ${requestHints.latitude}
+- lon: ${requestHints.longitude}
+- city: ${requestHints.city}
+- country: ${requestHints.country}
+`;
+
+export const systemPrompt = ({
+ requestHints,
+}: {
+ requestHints: RequestHints;
+}) => {
+ return `${regularPrompt}\n\n${getRequestPromptFromHints(requestHints)}`;
+};
+
+export const codePrompt = `
+You are a code generator that creates self-contained, executable code snippets. When writing code:
+
+1. Each snippet must be complete and runnable on its own
+2. Use print/console.log to display outputs
+3. Keep snippets concise and focused
+4. Prefer standard library over external dependencies
+5. Handle potential errors gracefully
+6. Return meaningful output that demonstrates functionality
+7. Don't use interactive input functions
+8. Don't access files or network resources
+9. Don't use infinite loops
+`;
+
+export const sheetPrompt = `
+You are a spreadsheet creation assistant. Create a spreadsheet in CSV format based on the given prompt.
+
+Requirements:
+- Use clear, descriptive column headers
+- Include realistic sample data
+- Format numbers and dates consistently
+- Keep the data well-structured and meaningful
+`;
+
+export const updateDocumentPrompt = (
+ currentContent: string | null,
+ type: ArtifactKind,
+) => {
+ const mediaTypes: Record = {
+ code: 'script',
+ sheet: 'spreadsheet',
+ };
+ const mediaType = mediaTypes[type] ?? 'document';
+
+ return `Rewrite the following ${mediaType} based on the given prompt.
+
+${currentContent}`;
+};
+
+export const titlePrompt = `Generate a short chat title (2-5 words) summarizing the user's message.
+
+Output ONLY the title text. No prefixes, no formatting.
+
+Examples:
+- "what's the weather in nyc" → Weather in NYC
+- "help me write an essay about space" → Space Essay Help
+- "hi" → New Conversation
+- "debug my python code" → Python Debugging
+
+Never output hashtags, prefixes like "Title:", or quotes.`;
diff --git a/examples/chatbot-with-billing-with-clerk/lib/ai/tools/get-weather.ts b/examples/chatbot-with-billing-with-clerk/lib/ai/tools/get-weather.ts
new file mode 100644
index 00000000..fc2f4704
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/ai/tools/get-weather.ts
@@ -0,0 +1,78 @@
+import { tool } from 'ai';
+import { z } from 'zod';
+
+async function geocodeCity(
+ city: string,
+): Promise<{ latitude: number; longitude: number } | null> {
+ try {
+ const response = await fetch(
+ `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`,
+ );
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = await response.json();
+
+ if (!data.results || data.results.length === 0) {
+ return null;
+ }
+
+ const result = data.results[0];
+ return {
+ latitude: result.latitude,
+ longitude: result.longitude,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export const getWeather = tool({
+ description:
+ 'Get the current weather at a location. You can provide either coordinates or a city name.',
+ inputSchema: z.object({
+ latitude: z.number().optional(),
+ longitude: z.number().optional(),
+ city: z
+ .string()
+ .describe("City name (e.g., 'San Francisco', 'New York', 'London')")
+ .optional(),
+ }),
+ execute: async input => {
+ let latitude: number;
+ let longitude: number;
+
+ if (input.city) {
+ const coords = await geocodeCity(input.city);
+ if (!coords) {
+ return {
+ error: `Could not find coordinates for "${input.city}". Please check the city name.`,
+ };
+ }
+ latitude = coords.latitude;
+ longitude = coords.longitude;
+ } else if (input.latitude !== undefined && input.longitude !== undefined) {
+ latitude = input.latitude;
+ longitude = input.longitude;
+ } else {
+ return {
+ error:
+ 'Please provide either a city name or both latitude and longitude coordinates.',
+ };
+ }
+
+ const response = await fetch(
+ `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
+ );
+
+ const weatherData = await response.json();
+
+ if ('city' in input) {
+ weatherData.cityName = input.city;
+ }
+
+ return weatherData;
+ },
+});
diff --git a/examples/chatbot-with-billing-with-clerk/lib/constants.ts b/examples/chatbot-with-billing-with-clerk/lib/constants.ts
new file mode 100644
index 00000000..67af7881
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/constants.ts
@@ -0,0 +1,14 @@
+export const isProductionEnvironment = process.env.NODE_ENV === 'production';
+export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development';
+export const isTestEnvironment = Boolean(
+ process.env.PLAYWRIGHT_TEST_BASE_URL ||
+ process.env.PLAYWRIGHT ||
+ process.env.CI_PLAYWRIGHT,
+);
+
+export const suggestions = [
+ 'What are the advantages of using Next.js?',
+ "Write code to demonstrate Dijkstra's algorithm",
+ 'Help me write an essay about Silicon Valley',
+ 'What is the weather in San Francisco?',
+];
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/migrate.ts b/examples/chatbot-with-billing-with-clerk/lib/db/migrate.ts
new file mode 100644
index 00000000..e27dd976
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/migrate.ts
@@ -0,0 +1,33 @@
+import { config } from 'dotenv';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import { migrate } from 'drizzle-orm/postgres-js/migrator';
+import postgres from 'postgres';
+
+config({
+ path: '.env.local',
+});
+
+const runMigrate = async () => {
+ if (!process.env.POSTGRES_URL) {
+ console.log('POSTGRES_URL not defined, skipping migrations');
+ process.exit(0);
+ }
+
+ const connection = postgres(process.env.POSTGRES_URL, { max: 1 });
+ const db = drizzle(connection);
+
+ console.log('Running migrations...');
+
+ const start = Date.now();
+ await migrate(db, { migrationsFolder: './lib/db/migrations' });
+ const end = Date.now();
+
+ console.log('Migrations completed in', end - start, 'ms');
+ process.exit(0);
+};
+
+runMigrate().catch(err => {
+ console.error('Migration failed');
+ console.error(err);
+ process.exit(1);
+});
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/migrations/0000_initial.sql b/examples/chatbot-with-billing-with-clerk/lib/db/migrations/0000_initial.sql
new file mode 100644
index 00000000..77424976
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/migrations/0000_initial.sql
@@ -0,0 +1,67 @@
+CREATE TABLE IF NOT EXISTS "User" (
+ "id" text PRIMARY KEY NOT NULL,
+ "email" varchar(64) NOT NULL,
+ "password" varchar(64),
+ "name" text,
+ "emailVerified" boolean NOT NULL DEFAULT false,
+ "image" text,
+ "isAnonymous" boolean NOT NULL DEFAULT false,
+ "createdAt" timestamp DEFAULT now() NOT NULL,
+ "updatedAt" timestamp DEFAULT now() NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "Chat" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "createdAt" timestamp NOT NULL,
+ "title" text NOT NULL,
+ "userId" text NOT NULL REFERENCES "User"("id"),
+ "visibility" varchar NOT NULL DEFAULT 'private'
+);
+
+CREATE TABLE IF NOT EXISTS "Message_v2" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "chatId" uuid NOT NULL REFERENCES "Chat"("id"),
+ "role" varchar NOT NULL,
+ "parts" json NOT NULL,
+ "attachments" json NOT NULL,
+ "createdAt" timestamp NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "Vote_v2" (
+ "chatId" uuid NOT NULL REFERENCES "Chat"("id"),
+ "messageId" uuid NOT NULL REFERENCES "Message_v2"("id"),
+ "isUpvoted" boolean NOT NULL,
+ PRIMARY KEY ("chatId", "messageId")
+);
+
+CREATE TABLE IF NOT EXISTS "Document" (
+ "id" uuid DEFAULT gen_random_uuid() NOT NULL,
+ "createdAt" timestamp NOT NULL,
+ "title" text NOT NULL,
+ "content" text,
+ "text" varchar NOT NULL DEFAULT 'text',
+ "userId" text NOT NULL REFERENCES "User"("id"),
+ PRIMARY KEY ("id", "createdAt")
+);
+
+CREATE TABLE IF NOT EXISTS "Suggestion" (
+ "id" uuid DEFAULT gen_random_uuid() NOT NULL,
+ "documentId" uuid NOT NULL,
+ "documentCreatedAt" timestamp NOT NULL,
+ "originalText" text NOT NULL,
+ "suggestedText" text NOT NULL,
+ "description" text,
+ "isResolved" boolean NOT NULL DEFAULT false,
+ "userId" text NOT NULL REFERENCES "User"("id"),
+ "createdAt" timestamp NOT NULL,
+ PRIMARY KEY ("id"),
+ FOREIGN KEY ("documentId", "documentCreatedAt") REFERENCES "Document"("id", "createdAt")
+);
+
+CREATE TABLE IF NOT EXISTS "Stream" (
+ "id" uuid DEFAULT gen_random_uuid() NOT NULL,
+ "chatId" uuid NOT NULL,
+ "createdAt" timestamp NOT NULL,
+ PRIMARY KEY ("id"),
+ FOREIGN KEY ("chatId") REFERENCES "Chat"("id")
+);
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/migrations/meta/_journal.json b/examples/chatbot-with-billing-with-clerk/lib/db/migrations/meta/_journal.json
new file mode 100644
index 00000000..a39cb795
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/migrations/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1710000000000,
+ "tag": "0000_initial",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/queries.ts b/examples/chatbot-with-billing-with-clerk/lib/db/queries.ts
new file mode 100644
index 00000000..8dbee9e7
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/queries.ts
@@ -0,0 +1,262 @@
+import 'server-only';
+
+import { and, asc, desc, eq, gt, inArray, lt, type SQL } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import { auth, currentUser } from '@clerk/nextjs/server';
+import postgres from 'postgres';
+import type { VisibilityType } from '@/components/chat/visibility-selector';
+import { ChatbotError } from '../errors';
+import {
+ type Chat,
+ chat,
+ type DBMessage,
+ message,
+ stream,
+ type User,
+ user,
+ vote,
+} from './schema';
+
+const client = postgres(process.env.POSTGRES_URL ?? '');
+const db = drizzle(client);
+
+export async function ensureUser(): Promise {
+ const { userId: clerkId } = await auth();
+ if (!clerkId) return null;
+
+ const [existing] = await db.select().from(user).where(eq(user.id, clerkId));
+
+ if (existing) return existing;
+
+ const clerkUser = await currentUser();
+ const email =
+ clerkUser?.primaryEmailAddress?.emailAddress ?? `${clerkId}@clerk`;
+
+ const [created] = await db
+ .insert(user)
+ .values({ id: clerkId, email })
+ .returning();
+ return created;
+}
+
+export async function getUserId(): Promise {
+ const u = await ensureUser();
+ return u?.id ?? null;
+}
+
+export async function saveChat({
+ id,
+ userId,
+ title,
+ visibility,
+}: {
+ id: string;
+ userId: string;
+ title: string;
+ visibility: VisibilityType;
+}) {
+ try {
+ return await db.insert(chat).values({
+ id,
+ createdAt: new Date(),
+ userId,
+ title,
+ visibility,
+ });
+ } catch (_error) {
+ throw new ChatbotError('bad_request:database', 'Failed to save chat');
+ }
+}
+
+export async function deleteChatById({ id }: { id: string }) {
+ try {
+ await db.delete(vote).where(eq(vote.chatId, id));
+ await db.delete(message).where(eq(message.chatId, id));
+ await db.delete(stream).where(eq(stream.chatId, id));
+
+ const [chatsDeleted] = await db
+ .delete(chat)
+ .where(eq(chat.id, id))
+ .returning();
+ return chatsDeleted;
+ } catch (_error) {
+ throw new ChatbotError(
+ 'bad_request:database',
+ 'Failed to delete chat by id',
+ );
+ }
+}
+
+export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
+ try {
+ const userChats = await db
+ .select({ id: chat.id })
+ .from(chat)
+ .where(eq(chat.userId, userId));
+
+ if (userChats.length === 0) {
+ return { deletedCount: 0 };
+ }
+
+ const chatIds = userChats.map(c => c.id);
+
+ await db.delete(vote).where(inArray(vote.chatId, chatIds));
+ await db.delete(message).where(inArray(message.chatId, chatIds));
+ await db.delete(stream).where(inArray(stream.chatId, chatIds));
+
+ const deletedChats = await db
+ .delete(chat)
+ .where(eq(chat.userId, userId))
+ .returning();
+
+ return { deletedCount: deletedChats.length };
+ } catch (_error) {
+ throw new ChatbotError(
+ 'bad_request:database',
+ 'Failed to delete all chats by user id',
+ );
+ }
+}
+
+export async function getChatsByUserId({
+ id,
+ limit,
+ startingAfter,
+ endingBefore,
+}: {
+ id: string;
+ limit: number;
+ startingAfter: string | null;
+ endingBefore: string | null;
+}) {
+ try {
+ const extendedLimit = limit + 1;
+
+ const query = (whereCondition?: SQL) =>
+ db
+ .select()
+ .from(chat)
+ .where(
+ whereCondition
+ ? and(whereCondition, eq(chat.userId, id))
+ : eq(chat.userId, id),
+ )
+ .orderBy(desc(chat.createdAt))
+ .limit(extendedLimit);
+
+ let filteredChats: Chat[] = [];
+
+ if (startingAfter) {
+ const [selectedChat] = await db
+ .select()
+ .from(chat)
+ .where(eq(chat.id, startingAfter))
+ .limit(1);
+
+ if (!selectedChat) {
+ throw new ChatbotError(
+ 'not_found:database',
+ `Chat with id ${startingAfter} not found`,
+ );
+ }
+
+ filteredChats = await query(gt(chat.createdAt, selectedChat.createdAt));
+ } else if (endingBefore) {
+ const [selectedChat] = await db
+ .select()
+ .from(chat)
+ .where(eq(chat.id, endingBefore))
+ .limit(1);
+
+ if (!selectedChat) {
+ throw new ChatbotError(
+ 'not_found:database',
+ `Chat with id ${endingBefore} not found`,
+ );
+ }
+
+ filteredChats = await query(lt(chat.createdAt, selectedChat.createdAt));
+ } else {
+ filteredChats = await query();
+ }
+
+ const hasMore = filteredChats.length > limit;
+
+ return {
+ chats: hasMore ? filteredChats.slice(0, limit) : filteredChats,
+ hasMore,
+ };
+ } catch (_error) {
+ throw new ChatbotError(
+ 'bad_request:database',
+ 'Failed to get chats by user id',
+ );
+ }
+}
+
+export async function getChatById({ id }: { id: string }) {
+ try {
+ const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id));
+ if (!selectedChat) {
+ return null;
+ }
+
+ return selectedChat;
+ } catch (_error) {
+ throw new ChatbotError('bad_request:database', 'Failed to get chat by id');
+ }
+}
+
+export async function saveMessages({ messages }: { messages: DBMessage[] }) {
+ try {
+ return await db.insert(message).values(messages);
+ } catch (_error) {
+ throw new ChatbotError('bad_request:database', 'Failed to save messages');
+ }
+}
+
+export async function getMessagesByChatId({ id }: { id: string }) {
+ try {
+ return await db
+ .select()
+ .from(message)
+ .where(eq(message.chatId, id))
+ .orderBy(asc(message.createdAt));
+ } catch (_error) {
+ throw new ChatbotError(
+ 'bad_request:database',
+ 'Failed to get messages by chat id',
+ );
+ }
+}
+
+export async function updateChatVisibilityById({
+ chatId,
+ visibility,
+}: {
+ chatId: string;
+ visibility: 'private' | 'public';
+}) {
+ try {
+ return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId));
+ } catch (_error) {
+ throw new ChatbotError(
+ 'bad_request:database',
+ 'Failed to update chat visibility by id',
+ );
+ }
+}
+
+export async function updateChatTitleById({
+ chatId,
+ title,
+}: {
+ chatId: string;
+ title: string;
+}) {
+ try {
+ return await db.update(chat).set({ title }).where(eq(chat.id, chatId));
+ } catch (_error) {
+ return;
+ }
+}
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/schema.ts b/examples/chatbot-with-billing-with-clerk/lib/db/schema.ts
new file mode 100644
index 00000000..d8d1b560
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/schema.ts
@@ -0,0 +1,136 @@
+import type { InferSelectModel } from 'drizzle-orm';
+import {
+ boolean,
+ foreignKey,
+ json,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uuid,
+ varchar,
+} from 'drizzle-orm/pg-core';
+
+export const user = pgTable('User', {
+ id: text('id').primaryKey().notNull(),
+ email: varchar('email', { length: 64 }).notNull(),
+ password: varchar('password', { length: 64 }),
+ name: text('name'),
+ emailVerified: boolean('emailVerified').notNull().default(false),
+ image: text('image'),
+ isAnonymous: boolean('isAnonymous').notNull().default(false),
+ createdAt: timestamp('createdAt').notNull().defaultNow(),
+ updatedAt: timestamp('updatedAt').notNull().defaultNow(),
+});
+
+export type User = InferSelectModel;
+
+export const chat = pgTable('Chat', {
+ id: uuid('id').primaryKey().notNull().defaultRandom(),
+ createdAt: timestamp('createdAt').notNull(),
+ title: text('title').notNull(),
+ userId: text('userId')
+ .notNull()
+ .references(() => user.id),
+ visibility: varchar('visibility', { enum: ['public', 'private'] })
+ .notNull()
+ .default('private'),
+});
+
+export type Chat = InferSelectModel;
+
+export const message = pgTable('Message_v2', {
+ id: uuid('id').primaryKey().notNull().defaultRandom(),
+ chatId: uuid('chatId')
+ .notNull()
+ .references(() => chat.id),
+ role: varchar('role').notNull(),
+ parts: json('parts').notNull(),
+ attachments: json('attachments').notNull(),
+ createdAt: timestamp('createdAt').notNull(),
+});
+
+export type DBMessage = InferSelectModel;
+
+export const vote = pgTable(
+ 'Vote_v2',
+ {
+ chatId: uuid('chatId')
+ .notNull()
+ .references(() => chat.id),
+ messageId: uuid('messageId')
+ .notNull()
+ .references(() => message.id),
+ isUpvoted: boolean('isUpvoted').notNull(),
+ },
+ table => ({
+ pk: primaryKey({ columns: [table.chatId, table.messageId] }),
+ }),
+);
+
+export type Vote = InferSelectModel;
+
+export const document = pgTable(
+ 'Document',
+ {
+ id: uuid('id').notNull().defaultRandom(),
+ createdAt: timestamp('createdAt').notNull(),
+ title: text('title').notNull(),
+ content: text('content'),
+ kind: varchar('text', { enum: ['text', 'code', 'image', 'sheet'] })
+ .notNull()
+ .default('text'),
+ userId: text('userId')
+ .notNull()
+ .references(() => user.id),
+ },
+ table => ({
+ pk: primaryKey({ columns: [table.id, table.createdAt] }),
+ }),
+);
+
+export type Document = InferSelectModel;
+
+export const suggestion = pgTable(
+ 'Suggestion',
+ {
+ id: uuid('id').notNull().defaultRandom(),
+ documentId: uuid('documentId').notNull(),
+ documentCreatedAt: timestamp('documentCreatedAt').notNull(),
+ originalText: text('originalText').notNull(),
+ suggestedText: text('suggestedText').notNull(),
+ description: text('description'),
+ isResolved: boolean('isResolved').notNull().default(false),
+ userId: text('userId')
+ .notNull()
+ .references(() => user.id),
+ createdAt: timestamp('createdAt').notNull(),
+ },
+ table => ({
+ pk: primaryKey({ columns: [table.id] }),
+ documentRef: foreignKey({
+ columns: [table.documentId, table.documentCreatedAt],
+ foreignColumns: [document.id, document.createdAt],
+ }),
+ }),
+);
+
+export type Suggestion = InferSelectModel;
+
+export const stream = pgTable(
+ 'Stream',
+ {
+ id: uuid('id').notNull().defaultRandom(),
+ chatId: uuid('chatId').notNull(),
+ createdAt: timestamp('createdAt').notNull(),
+ },
+ table => ({
+ pk: primaryKey({ columns: [table.id] }),
+ chatRef: foreignKey({
+ columns: [table.chatId],
+ foreignColumns: [chat.id],
+ }),
+ }),
+);
+
+export type Stream = InferSelectModel;
diff --git a/examples/chatbot-with-billing-with-clerk/lib/db/utils.ts b/examples/chatbot-with-billing-with-clerk/lib/db/utils.ts
new file mode 100644
index 00000000..25c866fc
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/db/utils.ts
@@ -0,0 +1,4 @@
+export {};
+
+// bcrypt-ts and password functions removed as auth is now handled by Clerk.
+// generateHashedPassword and generateDummyPassword are no longer needed.
diff --git a/examples/chatbot-with-billing-with-clerk/lib/errors.ts b/examples/chatbot-with-billing-with-clerk/lib/errors.ts
new file mode 100644
index 00000000..1d3e1e55
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/errors.ts
@@ -0,0 +1,137 @@
+export type ErrorType =
+ | 'bad_request'
+ | 'unauthorized'
+ | 'forbidden'
+ | 'not_found'
+ | 'rate_limit'
+ | 'offline';
+
+export type Surface =
+ | 'chat'
+ | 'auth'
+ | 'api'
+ | 'stream'
+ | 'database'
+ | 'history'
+ | 'vote'
+ | 'document'
+ | 'suggestions'
+ | 'activate_gateway';
+
+export type ErrorCode = `${ErrorType}:${Surface}`;
+
+export type ErrorVisibility = 'response' | 'log' | 'none';
+
+export const visibilityBySurface: Record = {
+ database: 'log',
+ chat: 'response',
+ auth: 'response',
+ stream: 'response',
+ api: 'response',
+ history: 'response',
+ vote: 'response',
+ document: 'response',
+ suggestions: 'response',
+ activate_gateway: 'response',
+};
+
+export class ChatbotError extends Error {
+ type: ErrorType;
+ surface: Surface;
+ statusCode: number;
+
+ constructor(errorCode: ErrorCode, cause?: string) {
+ super();
+
+ const [type, surface] = errorCode.split(':');
+
+ this.type = type as ErrorType;
+ this.cause = cause;
+ this.surface = surface as Surface;
+ this.message = getMessageByErrorCode(errorCode);
+ this.statusCode = getStatusCodeByType(this.type);
+ }
+
+ toResponse() {
+ const code: ErrorCode = `${this.type}:${this.surface}`;
+ const visibility = visibilityBySurface[this.surface];
+
+ const { message, cause, statusCode } = this;
+
+ if (visibility === 'log') {
+ console.error({
+ code,
+ message,
+ cause,
+ });
+
+ return Response.json(
+ { code: '', message: 'Something went wrong. Please try again later.' },
+ { status: statusCode },
+ );
+ }
+
+ return Response.json({ code, message, cause }, { status: statusCode });
+ }
+}
+
+export function getMessageByErrorCode(errorCode: ErrorCode): string {
+ if (errorCode.includes('database')) {
+ return 'An error occurred while executing a database query.';
+ }
+
+ switch (errorCode) {
+ case 'bad_request:api':
+ return "The request couldn't be processed. Please check your input and try again.";
+
+ case 'bad_request:activate_gateway':
+ return 'AI Gateway requires a valid credit card on file to service requests. Please visit https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card to add a card and unlock your free credits.';
+
+ case 'unauthorized:auth':
+ return 'You need to sign in before continuing.';
+ case 'forbidden:auth':
+ return 'Your account does not have access to this feature.';
+
+ case 'rate_limit:chat':
+ return "You've reached the message limit. Come back in 1 hour to continue chatting.";
+ case 'not_found:chat':
+ return 'The requested chat was not found. Please check the chat ID and try again.';
+ case 'forbidden:chat':
+ return 'This chat belongs to another user. Please check the chat ID and try again.';
+ case 'unauthorized:chat':
+ return 'You need to sign in to view this chat. Please sign in and try again.';
+ case 'offline:chat':
+ return "We're having trouble sending your message. Please check your internet connection and try again.";
+
+ case 'not_found:document':
+ return 'The requested document was not found. Please check the document ID and try again.';
+ case 'forbidden:document':
+ return 'This document belongs to another user. Please check the document ID and try again.';
+ case 'unauthorized:document':
+ return 'You need to sign in to view this document. Please sign in and try again.';
+ case 'bad_request:document':
+ return 'The request to create or update the document was invalid. Please check your input and try again.';
+
+ default:
+ return 'Something went wrong. Please try again later.';
+ }
+}
+
+function getStatusCodeByType(type: ErrorType) {
+ switch (type) {
+ case 'bad_request':
+ return 400;
+ case 'unauthorized':
+ return 401;
+ case 'forbidden':
+ return 403;
+ case 'not_found':
+ return 404;
+ case 'rate_limit':
+ return 429;
+ case 'offline':
+ return 503;
+ default:
+ return 500;
+ }
+}
diff --git a/examples/chatbot-with-billing-with-clerk/lib/polar-client.ts b/examples/chatbot-with-billing-with-clerk/lib/polar-client.ts
new file mode 100644
index 00000000..574678d6
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/polar-client.ts
@@ -0,0 +1,31 @@
+import { Polar } from '@polar-sh/sdk';
+
+let _polar: Polar | null = null;
+
+function getPolarClient(): Polar | null {
+ if (_polar) return _polar;
+
+ const accessToken = process.env.POLAR_ACCESS_TOKEN;
+ if (!accessToken) return null;
+
+ const server = (process.env.POLAR_SERVER ?? 'sandbox') as
+ | 'sandbox'
+ | 'production';
+
+ _polar = new Polar({ accessToken, server });
+ return _polar;
+}
+
+export async function createPolarCustomer(
+ email: string,
+ userId: string,
+): Promise {
+ const polar = getPolarClient();
+ if (!polar) return;
+
+ try {
+ await polar.customers.create({ email, externalId: userId });
+ } catch (error) {
+ console.error('[ai-billing] Failed to create Polar customer:', error);
+ }
+}
diff --git a/examples/chatbot-with-billing-with-clerk/lib/types.ts b/examples/chatbot-with-billing-with-clerk/lib/types.ts
new file mode 100644
index 00000000..bce37938
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/types.ts
@@ -0,0 +1,26 @@
+import type { UIMessage } from 'ai';
+import { z } from 'zod';
+import type { getWeather } from './ai/tools/get-weather';
+import type { InferUITool } from 'ai';
+
+export const messageMetadataSchema = z.object({
+ createdAt: z.string(),
+});
+
+export type MessageMetadata = z.infer;
+
+export type ChatTools = {
+ getWeather: InferUITool;
+};
+
+export type ChatMessage = UIMessage<
+ MessageMetadata,
+ Record,
+ ChatTools
+>;
+
+export type Attachment = {
+ name: string;
+ url: string;
+ contentType: string;
+};
diff --git a/examples/chatbot-with-billing-with-clerk/lib/utils.ts b/examples/chatbot-with-billing-with-clerk/lib/utils.ts
new file mode 100644
index 00000000..54a66ad4
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/lib/utils.ts
@@ -0,0 +1,88 @@
+import type { UIMessage, UIMessagePart } from 'ai';
+import { type ClassValue, clsx } from 'clsx';
+import { formatISO } from 'date-fns';
+import { twMerge } from 'tailwind-merge';
+import type { DBMessage, Document } from '@/lib/db/schema';
+import { ChatbotError, type ErrorCode } from './errors';
+import type { ChatMessage, ChatTools } from './types';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export const fetcher = async (url: string) => {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ const { code, cause } = await response.json();
+ throw new ChatbotError(code as ErrorCode, cause);
+ }
+
+ return response.json();
+};
+
+export async function fetchWithErrorHandlers(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+) {
+ try {
+ const response = await fetch(input, init);
+
+ if (!response.ok) {
+ const { code, cause } = await response.json();
+ throw new ChatbotError(code as ErrorCode, cause);
+ }
+
+ return response;
+ } catch (error: unknown) {
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
+ throw new ChatbotError('offline:chat');
+ }
+
+ throw error;
+ }
+}
+
+export function generateUUID(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+export function getDocumentTimestampByIndex(
+ documents: Document[],
+ index: number,
+) {
+ if (!documents) {
+ return new Date();
+ }
+ if (index > documents.length) {
+ return new Date();
+ }
+
+ return documents[index].createdAt;
+}
+
+export function sanitizeText(text: string) {
+ return text.replace('', '');
+}
+
+export function convertToUIMessages(messages: DBMessage[]): ChatMessage[] {
+ return messages.map(message => ({
+ id: message.id,
+ role: message.role as 'user' | 'assistant' | 'system',
+ parts: message.parts as UIMessagePart, ChatTools>[],
+ metadata: {
+ createdAt: formatISO(message.createdAt),
+ },
+ }));
+}
+
+export function getTextFromMessage(message: ChatMessage | UIMessage): string {
+ return message.parts
+ .filter(part => part.type === 'text')
+ .map(part => (part as { type: 'text'; text: string }).text)
+ .join('');
+}
diff --git a/examples/chatbot-with-billing-with-clerk/next.config.ts b/examples/chatbot-with-billing-with-clerk/next.config.ts
new file mode 100644
index 00000000..98292a47
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/next.config.ts
@@ -0,0 +1,50 @@
+import { withBotId } from 'botid/next/config';
+import type { NextConfig } from 'next';
+
+const basePath = process.env.IS_DEMO === '1' ? '/demo' : '';
+
+const nextConfig: NextConfig = {
+ ...(basePath
+ ? {
+ basePath,
+ assetPrefix: '/demo-assets',
+ redirects: async () => [
+ {
+ source: '/',
+ destination: basePath,
+ permanent: false,
+ basePath: false,
+ },
+ ],
+ }
+ : {}),
+ env: {
+ NEXT_PUBLIC_BASE_PATH: basePath,
+ },
+ cacheComponents: true,
+ devIndicators: false,
+ poweredByHeader: false,
+ reactCompiler: true,
+ logging: {
+ fetches: {
+ fullUrl: false,
+ },
+ incomingRequests: false,
+ },
+ images: {
+ remotePatterns: [
+ {
+ hostname: 'avatar.vercel.sh',
+ },
+ ],
+ },
+ experimental: {
+ prefetchInlining: true,
+ cachedNavigations: true,
+ appNewScrollHandler: true,
+ inlineCss: true,
+ turbopackFileSystemCacheForDev: true,
+ },
+};
+
+export default withBotId(nextConfig);
diff --git a/examples/chatbot-with-billing-with-clerk/package.json b/examples/chatbot-with-billing-with-clerk/package.json
new file mode 100644
index 00000000..2eb56fa8
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/package.json
@@ -0,0 +1,115 @@
+{
+ "name": "chatbot-with-billing-with-clerk",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --turbo --port 3000",
+ "build": "tsx lib/db/migrate && next build",
+ "start": "next start",
+ "lint": "oxlint",
+ "format": "oxfmt .",
+ "format:check": "oxfmt --check .",
+ "check-types": "next typegen && tsc --noEmit",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "npx tsx lib/db/migrate.ts",
+ "db:studio": "drizzle-kit studio",
+ "db:push": "drizzle-kit push",
+ "db:pull": "drizzle-kit pull",
+ "db:check": "drizzle-kit check",
+ "db:up": "drizzle-kit up",
+ "test:e2e": "PLAYWRIGHT_BROWSERS_PATH=0 PLAYWRIGHT=True pnpm exec playwright test",
+ "test:e2e:setup": "PLAYWRIGHT_BROWSERS_PATH=0 playwright install --with-deps chromium",
+ "test:report": "pnpm exec playwright show-report"
+ },
+ "dependencies": {
+ "@ai-billing/nextjs": "workspace:*",
+ "@ai-sdk/openai": "^3.0.0",
+ "@ai-sdk/provider": "catalog:",
+ "@clerk/nextjs": "^6",
+ "@codemirror/lang-python": "^6.1.6",
+ "@codemirror/state": "^6.5.0",
+ "@codemirror/theme-one-dark": "^6.1.2",
+ "@codemirror/view": "^6.35.3",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.200.0",
+ "@opentelemetry/instrumentation": "^0.216.0",
+ "@opentelemetry/resources": "^2.7.1",
+ "@opentelemetry/sdk-logs": "^0.216.0",
+ "@opentelemetry/sdk-metrics": "^2.7.1",
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
+ "@polar-sh/sdk": "^0.46.7",
+ "@radix-ui/react-use-controllable-state": "^1.2.2",
+ "@streamdown/cjk": "^1.0.2",
+ "@streamdown/code": "^1.0.3",
+ "@streamdown/math": "^1.0.2",
+ "@streamdown/mermaid": "^1.0.2",
+ "@vercel/analytics": "^1.3.1",
+ "@vercel/functions": "^2.0.0",
+ "@vercel/otel": "^2.1.2",
+ "ai": "6.0.116",
+ "botid": "^1.5.11",
+ "class-variance-authority": "^0.7.1",
+ "classnames": "^2.5.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "codemirror": "^6.0.1",
+ "date-fns": "^4.1.0",
+ "diff-match-patch": "^1.0.5",
+ "dotenv": "^16.4.5",
+ "drizzle-orm": "^0.45.2",
+ "fast-deep-equal": "^3.1.3",
+ "framer-motion": "^11.3.19",
+ "katex": "^0.16.28",
+ "lucide-react": "^0.446.0",
+ "motion": "^12.23.26",
+ "nanoid": "^5.1.3",
+ "next": "catalog:",
+ "next-themes": "^0.4.6",
+ "orderedmap": "^2.1.1",
+ "papaparse": "^5.5.2",
+ "postgres": "^3.4.4",
+ "prosemirror-example-setup": "^1.2.3",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-model": "^1.23.0",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.4.1",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-view": "^1.34.3",
+ "radix-ui": "^1.4.3",
+ "react": "catalog:",
+ "react-data-grid": "7.0.0-beta.47",
+ "react-dom": "catalog:",
+ "server-only": "^0.0.1",
+ "shiki": "^3.21.0",
+ "sonner": "^1.5.0",
+ "streamdown": "^2.3.0",
+ "swr": "^2.2.5",
+ "tailwind-merge": "^2.5.2",
+ "tailwindcss-animate": "^1.0.7",
+ "use-stick-to-bottom": "^1.1.1",
+ "usehooks-ts": "^3.1.0",
+ "zod": "^4.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tailwindcss/postcss": "^4.1.13",
+ "@tailwindcss/typography": "^0.5.15",
+ "@types/d3-scale": "^4.0.8",
+ "@types/node": "^22.8.6",
+ "@types/papaparse": "^5.3.15",
+ "@types/pdf-parse": "^1.1.4",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "drizzle-kit": "^0.25.0",
+ "oxfmt": "catalog:",
+ "oxlint": "catalog:",
+ "postcss": "^8",
+ "tailwindcss": "^4.1.13",
+ "tsx": "^4.19.1",
+ "typescript": "^5.6.3",
+ "ultracite": "^7.0.11"
+ },
+ "packageManager": "pnpm@10.32.1"
+}
diff --git a/examples/chatbot-with-billing-with-clerk/playwright.config.ts b/examples/chatbot-with-billing-with-clerk/playwright.config.ts
new file mode 100644
index 00000000..067d4b3b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/playwright.config.ts
@@ -0,0 +1,103 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+import { config } from 'dotenv';
+
+if (!process.env.CI) {
+ config({ path: '.env.local', override: true });
+} else {
+ // Otherwise, load nothing (GitHub Actions will provide the real env vars)
+ config();
+}
+
+/* Use process.env.PORT by default and fallback to port 3000 */
+const PORT = process.env.PORT || 3000;
+
+/**
+ * Set webServer.url and use.baseURL with the location
+ * of the WebServer respecting the correct set port
+ */
+const baseURL = `http://localhost:${PORT}`;
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 3 : 0,
+ /* Limit workers to prevent browser crashes */
+ workers: process.env.CI ? 1 : 2,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: [['html', { open: 'never' }]],
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL,
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'retain-on-failure',
+ },
+
+ /* Configure global timeout for each test */
+ timeout: process.env.CI ? 60 * 1000 : 30 * 1000,
+ expect: {
+ timeout: process.env.CI ? 60 * 1000 : 30 * 1000,
+ },
+
+ /* Configure projects */
+ projects: [
+ {
+ name: 'e2e',
+ testMatch: /e2e\/.*.test.ts/,
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+
+ // {
+ // name: 'firefox',
+ // use: { ...devices['Desktop Firefox'] },
+ // },
+
+ // {
+ // name: 'webkit',
+ // use: { ...devices['Desktop Safari'] },
+ // },
+
+ /* Test against mobile viewports. */
+ // {
+ // name: 'Mobile Chrome',
+ // use: { ...devices['Pixel 5'] },
+ // },
+ // {
+ // name: 'Mobile Safari',
+ // use: { ...devices['iPhone 12'] },
+ // },
+
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: process.env.CI ? 'pnpm start' : 'pnpm dev',
+ url: `${baseURL}/ping`,
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/examples/chatbot-with-billing-with-clerk/postcss.config.mjs b/examples/chatbot-with-billing-with-clerk/postcss.config.mjs
new file mode 100644
index 00000000..79bcf135
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/examples/chatbot-with-billing-with-clerk/proxy.ts b/examples/chatbot-with-billing-with-clerk/proxy.ts
new file mode 100644
index 00000000..9803ca53
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/proxy.ts
@@ -0,0 +1,10 @@
+import { clerkMiddleware } from '@clerk/nextjs/server';
+
+export default clerkMiddleware();
+
+export const config = {
+ matcher: [
+ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
+ '/(api|trpc)(.*)',
+ ],
+};
diff --git a/examples/chatbot-with-billing-with-clerk/public/images/demo-thumbnail.png b/examples/chatbot-with-billing-with-clerk/public/images/demo-thumbnail.png
new file mode 100644
index 00000000..8c6f98ab
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/public/images/demo-thumbnail.png differ
diff --git a/examples/chatbot-with-billing-with-clerk/public/images/mouth of the seine, monet.jpg b/examples/chatbot-with-billing-with-clerk/public/images/mouth of the seine, monet.jpg
new file mode 100644
index 00000000..62515e56
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/public/images/mouth of the seine, monet.jpg differ
diff --git a/examples/chatbot-with-billing-with-clerk/public/preview.png b/examples/chatbot-with-billing-with-clerk/public/preview.png
new file mode 100644
index 00000000..fb47a510
Binary files /dev/null and b/examples/chatbot-with-billing-with-clerk/public/preview.png differ
diff --git a/examples/chatbot-with-billing-with-clerk/tests/e2e/api.test.ts b/examples/chatbot-with-billing-with-clerk/tests/e2e/api.test.ts
new file mode 100644
index 00000000..502f516b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/e2e/api.test.ts
@@ -0,0 +1,95 @@
+import { expect, test } from '@playwright/test';
+
+const CHAT_URL_REGEX = /\/chat\/[\w-]+/;
+const ERROR_TEXT_REGEX = /error|failed|trouble/i;
+
+test.describe('Chat API Integration', () => {
+ test('sends message and receives AI response', async ({ page }) => {
+ await page.goto('/');
+
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Hello');
+ await page.getByTestId('send-button').click();
+
+ // Wait for assistant response to appear
+ const assistantMessage = page.locator("[data-role='assistant']").first();
+ await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
+
+ // Verify it has some text content
+ const content = await assistantMessage.textContent();
+ expect(content?.length).toBeGreaterThan(0);
+ });
+
+ test('redirects to /chat/:id after sending message', async ({ page }) => {
+ await page.goto('/');
+
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Test redirect');
+ await page.getByTestId('send-button').click();
+
+ // URL should change to /chat/:id format
+ await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
+ });
+
+ test('clears input after sending', async ({ page }) => {
+ await page.goto('/');
+
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Test message');
+ await page.getByTestId('send-button').click();
+
+ // Input should be cleared
+ await expect(input).toHaveValue('');
+ });
+
+ test('shows stop button during generation', async ({ page }) => {
+ await page.goto('/');
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Test');
+ await page.getByTestId('send-button').click();
+
+ // Stop button should appear during generation
+ const stopButton = page.getByTestId('stop-button');
+ await expect(stopButton).toBeVisible({ timeout: 5000 });
+ });
+});
+
+test.describe('Chat Error Handling', () => {
+ test('handles API error gracefully', async ({ page }) => {
+ await page.route('**/api/chat', async route => {
+ await route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'Internal server error' }),
+ });
+ });
+
+ await page.goto('/');
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Test error');
+ await page.getByTestId('send-button').click();
+
+ // Should show error toast or message
+ await expect(page.getByText(ERROR_TEXT_REGEX).first()).toBeVisible({
+ timeout: 5000,
+ });
+ });
+});
+
+test.describe('Suggested Actions', () => {
+ test('suggested actions are clickable', async ({ page }) => {
+ await page.goto('/');
+
+ const suggestions = page.locator(
+ "[data-testid='suggested-actions'] button",
+ );
+ const count = await suggestions.count();
+
+ if (count > 0) {
+ await suggestions.first().click();
+
+ // Should redirect after clicking suggestion
+ await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
+ }
+ });
+});
diff --git a/examples/chatbot-with-billing-with-clerk/tests/e2e/auth.test.ts b/examples/chatbot-with-billing-with-clerk/tests/e2e/auth.test.ts
new file mode 100644
index 00000000..52b5847d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/e2e/auth.test.ts
@@ -0,0 +1,31 @@
+import { expect, test } from '@playwright/test';
+
+test.describe('Authentication Pages', () => {
+ test('login page renders correctly', async ({ page }) => {
+ await page.goto('/login');
+ await expect(page.getByPlaceholder('user@acme.com')).toBeVisible();
+ await expect(page.getByLabel('Password')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
+ await expect(page.getByText('No account?')).toBeVisible();
+ });
+
+ test('register page renders correctly', async ({ page }) => {
+ await page.goto('/register');
+ await expect(page.getByPlaceholder('user@acme.com')).toBeVisible();
+ await expect(page.getByLabel('Password')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Sign Up' })).toBeVisible();
+ await expect(page.getByText('Have an account?')).toBeVisible();
+ });
+
+ test('can navigate from login to register', async ({ page }) => {
+ await page.goto('/login');
+ await page.getByRole('link', { name: 'Sign up' }).click();
+ await expect(page).toHaveURL('/register');
+ });
+
+ test('can navigate from register to login', async ({ page }) => {
+ await page.goto('/register');
+ await page.getByRole('link', { name: 'Sign in' }).click();
+ await expect(page).toHaveURL('/login');
+ });
+});
diff --git a/examples/chatbot-with-billing-with-clerk/tests/e2e/chat.test.ts b/examples/chatbot-with-billing-with-clerk/tests/e2e/chat.test.ts
new file mode 100644
index 00000000..f39d14fc
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/e2e/chat.test.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test';
+
+test.describe('Chat Page', () => {
+ test('home page loads with input field', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByTestId('multimodal-input')).toBeVisible();
+ });
+
+ test('can type in the input field', async ({ page }) => {
+ await page.goto('/');
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Hello world');
+ await expect(input).toHaveValue('Hello world');
+ });
+
+ test('submit button is visible', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByTestId('send-button')).toBeVisible();
+ });
+
+ test('suggested actions are visible on empty chat', async ({ page }) => {
+ await page.goto('/');
+ const suggestions = page.locator("[data-testid='suggested-actions']");
+ await expect(suggestions).toBeVisible();
+ });
+
+ test('can stop generation with stop button', async ({ page }) => {
+ await page.goto('/');
+
+ // Type and send a message
+ await page.getByTestId('multimodal-input').fill('Hello');
+ await page.getByTestId('send-button').click();
+
+ // Stop button should appear during generation
+ const stopButton = page.getByTestId('stop-button');
+ // If generation starts, stop button appears
+ // This is a best-effort check since timing depends on API
+ await stopButton.click({ timeout: 5000 }).catch(() => {
+ // Generation may have finished before we could click
+ });
+ });
+});
+
+test.describe('Chat Input Features', () => {
+ test('input clears after sending', async ({ page }) => {
+ await page.goto('/');
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Test message');
+ await page.getByTestId('send-button').click();
+
+ // Input should clear after sending
+ await expect(input).toHaveValue('');
+ });
+
+ test('input supports multiline text', async ({ page }) => {
+ await page.goto('/');
+ const input = page.getByTestId('multimodal-input');
+ await input.fill('Line 1\nLine 2\nLine 3');
+ await expect(input).toContainText('Line 1');
+ });
+});
diff --git a/examples/chatbot-with-billing-with-clerk/tests/e2e/model-selector.test.ts b/examples/chatbot-with-billing-with-clerk/tests/e2e/model-selector.test.ts
new file mode 100644
index 00000000..92be7e6d
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/e2e/model-selector.test.ts
@@ -0,0 +1,70 @@
+import { expect, test } from '@playwright/test';
+
+const MODEL_BUTTON_REGEX = /Kimi|Codestral|Mistral|DeepSeek|GPT|Grok/i;
+
+test.describe('Model Selector', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ });
+
+ test('displays a model button', async ({ page }) => {
+ const modelButton = page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await expect(modelButton).toBeVisible();
+ });
+
+ test('opens model selector popover on click', async ({ page }) => {
+ const modelButton = page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await modelButton.click();
+
+ await expect(page.getByPlaceholder('Search models...')).toBeVisible();
+ });
+
+ test('can search for models', async ({ page }) => {
+ const modelButton = page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await modelButton.click();
+
+ const searchInput = page.getByPlaceholder('Search models...');
+ await searchInput.fill('Mistral');
+
+ await expect(page.getByText('Mistral Small').first()).toBeVisible();
+ });
+
+ test('can close model selector by clicking outside', async ({ page }) => {
+ const modelButton = page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await modelButton.click();
+
+ await expect(page.getByPlaceholder('Search models...')).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ await expect(page.getByPlaceholder('Search models...')).not.toBeVisible();
+ });
+
+ test('can select a different model', async ({ page }) => {
+ const modelButton = page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await modelButton.click();
+
+ await page.getByText('Mistral Small').first().click();
+
+ await expect(page.getByPlaceholder('Search models...')).not.toBeVisible();
+
+ await expect(
+ page.locator('button').filter({ hasText: 'Mistral Small' }).first(),
+ ).toBeVisible();
+ });
+});
diff --git a/examples/chatbot-with-billing-with-clerk/tests/fixtures.ts b/examples/chatbot-with-billing-with-clerk/tests/fixtures.ts
new file mode 100644
index 00000000..aebb8081
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/fixtures.ts
@@ -0,0 +1,15 @@
+import { expect as baseExpect, test as baseTest } from '@playwright/test';
+import { ChatPage } from './pages/chat';
+
+type Fixtures = {
+ chatPage: ChatPage;
+};
+
+export const test = baseTest.extend({
+ chatPage: async ({ page }, use) => {
+ const chatPage = new ChatPage(page);
+ await use(chatPage);
+ },
+});
+
+export const expect = baseExpect;
diff --git a/examples/chatbot-with-billing-with-clerk/tests/helpers.ts b/examples/chatbot-with-billing-with-clerk/tests/helpers.ts
new file mode 100644
index 00000000..eae4a388
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/helpers.ts
@@ -0,0 +1,16 @@
+import { generateId } from 'ai';
+import { getUnixTime } from 'date-fns';
+
+export function generateRandomTestUser() {
+ const email = `test-${getUnixTime(new Date())}@playwright.com`;
+ const password = generateId();
+
+ return {
+ email,
+ password,
+ };
+}
+
+export function generateTestMessage() {
+ return `Test message ${Date.now()}`;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/tests/pages/chat.ts b/examples/chatbot-with-billing-with-clerk/tests/pages/chat.ts
new file mode 100644
index 00000000..c5183b17
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/pages/chat.ts
@@ -0,0 +1,71 @@
+import type { Page } from '@playwright/test';
+
+const MODEL_BUTTON_REGEX = /Kimi|Codestral|Mistral|DeepSeek|GPT|Grok/i;
+
+export class ChatPage {
+ page: Page;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ async goto() {
+ await this.page.goto('/');
+ }
+
+ async createNewChat() {
+ await this.page.goto('/');
+ await this.page.waitForSelector("[data-testid='multimodal-input']");
+ }
+
+ getInput() {
+ return this.page.getByTestId('multimodal-input');
+ }
+
+ async typeMessage(message: string) {
+ const input = this.getInput();
+ await input.fill(message);
+ }
+
+ async sendMessage() {
+ await this.page.getByTestId('send-button').click();
+ }
+
+ async sendUserMessage(message: string) {
+ await this.typeMessage(message);
+ await this.sendMessage();
+ }
+
+ getSendButton() {
+ return this.page.getByTestId('send-button');
+ }
+
+ getStopButton() {
+ return this.page.getByTestId('stop-button');
+ }
+
+ async clickSuggestedAction(index = 0) {
+ const suggestions = this.page.locator(
+ "[data-testid='suggested-actions'] button",
+ );
+ await suggestions.nth(index).click();
+ }
+
+ async openModelSelector() {
+ const modelButton = this.page
+ .locator('button')
+ .filter({ hasText: MODEL_BUTTON_REGEX })
+ .first();
+ await modelButton.click();
+ }
+
+ async selectModel(modelName: string) {
+ await this.openModelSelector();
+ await this.page.getByText(modelName).first().click();
+ }
+
+ async searchModels(query: string) {
+ await this.openModelSelector();
+ await this.page.getByPlaceholder('Search models...').fill(query);
+ }
+}
diff --git a/examples/chatbot-with-billing-with-clerk/tests/prompts/utils.ts b/examples/chatbot-with-billing-with-clerk/tests/prompts/utils.ts
new file mode 100644
index 00000000..809306fe
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tests/prompts/utils.ts
@@ -0,0 +1,34 @@
+import type { LanguageModelV3StreamPart } from '@ai-sdk/provider';
+
+const mockUsage = {
+ inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
+ outputTokens: { total: 20, text: 20, reasoning: 0 },
+};
+
+export function getResponseChunksByPrompt(
+ _prompt: unknown,
+ includeReasoning = false,
+): LanguageModelV3StreamPart[] {
+ const chunks: LanguageModelV3StreamPart[] = [];
+
+ if (includeReasoning) {
+ chunks.push(
+ { type: 'reasoning-start', id: 'r1' },
+ { type: 'reasoning-delta', id: 'r1', delta: 'Let me think about this.' },
+ { type: 'reasoning-end', id: 'r1' },
+ );
+ }
+
+ chunks.push(
+ { type: 'text-start', id: 't1' },
+ { type: 'text-delta', id: 't1', delta: 'Hello, world!' },
+ { type: 'text-end', id: 't1' },
+ {
+ type: 'finish',
+ finishReason: { unified: 'stop', raw: 'stop' },
+ usage: mockUsage,
+ },
+ );
+
+ return chunks;
+}
diff --git a/examples/chatbot-with-billing-with-clerk/tsconfig.json b/examples/chatbot-with-billing-with-clerk/tsconfig.json
new file mode 100644
index 00000000..e11ae50b
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "strictNullChecks": true
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "next.config.js",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/chatbot-with-billing-with-clerk/vercel-template.json b/examples/chatbot-with-billing-with-clerk/vercel-template.json
new file mode 100644
index 00000000..748a1aed
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/vercel-template.json
@@ -0,0 +1,10 @@
+{
+ "products": [
+ {
+ "type": "integration",
+ "protocol": "storage",
+ "productSlug": "neon",
+ "integrationSlug": "neon"
+ }
+ ]
+}
diff --git a/examples/chatbot-with-billing-with-clerk/vercel.json b/examples/chatbot-with-billing-with-clerk/vercel.json
new file mode 100644
index 00000000..f92a3f8a
--- /dev/null
+++ b/examples/chatbot-with-billing-with-clerk/vercel.json
@@ -0,0 +1,3 @@
+{
+ "framework": "nextjs"
+}
diff --git a/knip.json b/knip.json
index 9879dd8b..d35b93ed 100644
--- a/knip.json
+++ b/knip.json
@@ -13,6 +13,7 @@
"examples/chatbot-gateway-with-billing-stripe-advanced/**",
"examples/chatbot-openai-with-billing-polar-advanced/**",
"examples/chatbot-with-billing/**",
+ "examples/chatbot-with-billing-with-clerk/**",
"apps/storybook/**"
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d902dc4b..a65e699a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1264,6 +1264,268 @@ importers:
specifier: ^7.0.11
version: 7.6.2(oxfmt@0.52.0)(oxlint@1.57.0)
+ examples/chatbot-with-billing-with-clerk:
+ dependencies:
+ '@ai-billing/nextjs':
+ specifier: workspace:*
+ version: link:../../packages/nextjs
+ '@ai-sdk/openai':
+ specifier: ^3.0.0
+ version: 3.0.49(zod@4.3.6)
+ '@ai-sdk/provider':
+ specifier: 'catalog:'
+ version: 3.0.8
+ '@clerk/nextjs':
+ specifier: ^6
+ version: 6.39.5(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@codemirror/lang-python':
+ specifier: ^6.1.6
+ version: 6.2.1
+ '@codemirror/state':
+ specifier: ^6.5.0
+ version: 6.6.0
+ '@codemirror/theme-one-dark':
+ specifier: ^6.1.2
+ version: 6.1.3
+ '@codemirror/view':
+ specifier: ^6.35.3
+ version: 6.41.1
+ '@opentelemetry/api':
+ specifier: ^1.9.0
+ version: 1.9.1
+ '@opentelemetry/api-logs':
+ specifier: ^0.200.0
+ version: 0.200.0
+ '@opentelemetry/instrumentation':
+ specifier: ^0.216.0
+ version: 0.216.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources':
+ specifier: ^2.7.1
+ version: 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs':
+ specifier: ^0.216.0
+ version: 0.216.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-metrics':
+ specifier: ^2.7.1
+ version: 2.7.1(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base':
+ specifier: ^2.7.1
+ version: 2.7.1(@opentelemetry/api@1.9.1)
+ '@polar-sh/sdk':
+ specifier: ^0.46.7
+ version: 0.46.7
+ '@radix-ui/react-use-controllable-state':
+ specifier: ^1.2.2
+ version: 1.2.2(@types/react@19.2.15)(react@19.2.4)
+ '@streamdown/cjk':
+ specifier: ^1.0.2
+ version: 1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)
+ '@streamdown/code':
+ specifier: ^1.0.3
+ version: 1.1.1(react@19.2.4)
+ '@streamdown/math':
+ specifier: ^1.0.2
+ version: 1.0.2(react@19.2.4)
+ '@streamdown/mermaid':
+ specifier: ^1.0.2
+ version: 1.0.2(react@19.2.4)
+ '@vercel/analytics':
+ specifier: ^1.3.1
+ version: 1.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ '@vercel/functions':
+ specifier: ^2.0.0
+ version: 2.2.13
+ '@vercel/otel':
+ specifier: ^2.1.2
+ version: 2.1.2(@opentelemetry/api-logs@0.200.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))
+ ai:
+ specifier: 6.0.116
+ version: 6.0.116(zod@4.3.6)
+ botid:
+ specifier: ^1.5.11
+ version: 1.5.11(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ classnames:
+ specifier: ^2.5.1
+ version: 2.5.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ codemirror:
+ specifier: ^6.0.1
+ version: 6.0.2
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
+ diff-match-patch:
+ specifier: ^1.0.5
+ version: 1.0.5
+ dotenv:
+ specifier: ^16.4.5
+ version: 16.6.1
+ drizzle-orm:
+ specifier: ^0.45.2
+ version: 0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9)
+ fast-deep-equal:
+ specifier: ^3.1.3
+ version: 3.1.3
+ framer-motion:
+ specifier: ^11.3.19
+ version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ katex:
+ specifier: ^0.16.28
+ version: 0.16.45
+ lucide-react:
+ specifier: ^0.446.0
+ version: 0.446.0(react@19.2.4)
+ motion:
+ specifier: ^12.23.26
+ version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ nanoid:
+ specifier: ^5.1.3
+ version: 5.1.11
+ next:
+ specifier: 'catalog:'
+ version: 16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ orderedmap:
+ specifier: ^2.1.1
+ version: 2.1.1
+ papaparse:
+ specifier: ^5.5.2
+ version: 5.5.3
+ postgres:
+ specifier: ^3.4.4
+ version: 3.4.9
+ prosemirror-example-setup:
+ specifier: ^1.2.3
+ version: 1.2.3
+ prosemirror-inputrules:
+ specifier: ^1.4.0
+ version: 1.5.1
+ prosemirror-markdown:
+ specifier: ^1.13.1
+ version: 1.13.4
+ prosemirror-model:
+ specifier: ^1.23.0
+ version: 1.25.4
+ prosemirror-schema-basic:
+ specifier: ^1.2.3
+ version: 1.2.4
+ prosemirror-schema-list:
+ specifier: ^1.4.1
+ version: 1.5.1
+ prosemirror-state:
+ specifier: ^1.4.3
+ version: 1.4.4
+ prosemirror-view:
+ specifier: ^1.34.3
+ version: 1.41.8
+ radix-ui:
+ specifier: ^1.4.3
+ version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 'catalog:'
+ version: 19.2.4
+ react-data-grid:
+ specifier: 7.0.0-beta.47
+ version: 7.0.0-beta.47(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
+ server-only:
+ specifier: ^0.0.1
+ version: 0.0.1
+ shiki:
+ specifier: ^3.21.0
+ version: 3.23.0
+ sonner:
+ specifier: ^1.5.0
+ version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ streamdown:
+ specifier: ^2.3.0
+ version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ swr:
+ specifier: ^2.2.5
+ version: 2.4.1(react@19.2.4)
+ tailwind-merge:
+ specifier: ^2.5.2
+ version: 2.6.1
+ tailwindcss-animate:
+ specifier: ^1.0.7
+ version: 1.0.7(tailwindcss@4.2.4)
+ use-stick-to-bottom:
+ specifier: ^1.1.1
+ version: 1.1.4(react@19.2.4)
+ usehooks-ts:
+ specifier: ^3.1.0
+ version: 3.1.1(react@19.2.4)
+ zod:
+ specifier: ^4.0.0
+ version: 4.3.6
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.50.1
+ version: 1.59.1
+ '@tailwindcss/postcss':
+ specifier: ^4.1.13
+ version: 4.2.4
+ '@tailwindcss/typography':
+ specifier: ^0.5.15
+ version: 0.5.19(tailwindcss@4.2.4)
+ '@types/d3-scale':
+ specifier: ^4.0.8
+ version: 4.0.9
+ '@types/node':
+ specifier: ^22.8.6
+ version: 22.19.15
+ '@types/papaparse':
+ specifier: ^5.3.15
+ version: 5.5.2
+ '@types/pdf-parse':
+ specifier: ^1.1.4
+ version: 1.1.5
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.15
+ '@types/react-dom':
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.15)
+ babel-plugin-react-compiler:
+ specifier: ^1.0.0
+ version: 1.0.0
+ drizzle-kit:
+ specifier: ^0.25.0
+ version: 0.25.0
+ oxfmt:
+ specifier: 'catalog:'
+ version: 0.52.0
+ oxlint:
+ specifier: 'catalog:'
+ version: 1.57.0
+ postcss:
+ specifier: '>=8.5.10'
+ version: 8.5.14
+ tailwindcss:
+ specifier: ^4.1.13
+ version: 4.2.4
+ tsx:
+ specifier: ^4.19.1
+ version: 4.21.0
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+ ultracite:
+ specifier: ^7.0.11
+ version: 7.6.2(oxfmt@0.52.0)(oxlint@1.57.0)
+
examples/dev-sandbox:
dependencies:
'@ai-billing/anthropic':
@@ -3119,6 +3381,41 @@ packages:
resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==}
engines: {node: '>= 20.12.0'}
+ '@clerk/backend@2.33.5':
+ resolution: {integrity: sha512-YOzUYJfb1d4w+0rKKm+LnnNpkJGQ+NI/g7qmF3mgaSN9X9huteuwCZyufdsI7z2DDkwy/yGRgb9eUWV96t7xLg==}
+ engines: {node: '>=18.17.0'}
+
+ '@clerk/clerk-react@5.61.8':
+ resolution: {integrity: sha512-n+k3q3xeyDkIPGTVA1J4Pd0+6MbS9Ia04qNlecOztTHwFfcirO5hy4TpOXrpGnO+GzYBuUMp7pYc3//ybMdEfg==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+ react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+
+ '@clerk/nextjs@6.39.5':
+ resolution: {integrity: sha512-hvdvpiuHXPhlx3iaNfoXO1joZkNP4Lzw83teUNPrzsbOX0rT9QE0uSxS2J/UEAeqoPK6JhNK7dZGvZ9knsB/mg==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ next: ^13.5.7 || ^14.2.25 || ^15.2.3 || ^16
+ react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+ react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+
+ '@clerk/shared@3.47.7':
+ resolution: {integrity: sha512-9Yv4MJFEaC7BzV0whxa4txQ4SoMu/3j1LBnI85EBykb5CcfXxIKvNX/9sjMUUySHlTOjsj7XZa5i3W5Dx02K/Q==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+ react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ '@clerk/types@4.101.25':
+ resolution: {integrity: sha512-gPxm3hlBkP7B9EfKyp3/UDonNOjg7Z0UvqfrMj5u8gA8nyzvC1UFYtSTTmTfgSY82+5Yo38YV0DYu9vNf6t9CQ==}
+ engines: {node: '>=18.17.0'}
+
'@codemirror/autocomplete@6.20.1':
resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==}
@@ -6626,6 +6923,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -7265,6 +7565,9 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ glob-to-regexp@0.4.1:
+ resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
+
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -7466,6 +7769,10 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
+ js-cookie@3.0.7:
+ resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
+ engines: {node: '>=20'}
+
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -8629,6 +8936,9 @@ packages:
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
@@ -8731,6 +9041,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ swr@2.3.4:
+ resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
@@ -9643,6 +9958,54 @@ snapshots:
fast-wrap-ansi: 0.2.0
sisteransi: 1.0.5
+ '@clerk/backend@2.33.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@clerk/shared': 3.47.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@clerk/types': 4.101.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ standardwebhooks: 1.0.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - react
+ - react-dom
+
+ '@clerk/clerk-react@5.61.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@clerk/shared': 3.47.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ tslib: 2.8.1
+
+ '@clerk/nextjs@6.39.5(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@clerk/backend': 2.33.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@clerk/clerk-react': 5.61.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@clerk/shared': 3.47.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@clerk/types': 4.101.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next: 16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ server-only: 0.0.1
+ tslib: 2.8.1
+
+ '@clerk/shared@3.47.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ csstype: 3.1.3
+ dequal: 2.0.3
+ glob-to-regexp: 0.4.1
+ js-cookie: 3.0.7
+ std-env: 3.10.0
+ swr: 2.3.4(react@19.2.4)
+ optionalDependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@clerk/types@4.101.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@clerk/shared': 3.47.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ transitivePeerDependencies:
+ - react
+ - react-dom
+
'@codemirror/autocomplete@6.20.1':
dependencies:
'@codemirror/language': 6.12.3
@@ -12625,6 +12988,8 @@ snapshots:
cssesc@3.0.0: {}
+ csstype@3.1.3: {}
+
csstype@3.2.3: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.3):
@@ -13260,6 +13625,8 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob-to-regexp@0.4.1: {}
+
glob@13.0.6:
dependencies:
minimatch: 10.2.5
@@ -13515,6 +13882,8 @@ snapshots:
joycon@3.1.1: {}
+ js-cookie@3.0.7: {}
+
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@@ -15122,6 +15491,8 @@ snapshots:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
+ std-env@3.10.0: {}
+
std-env@4.0.0: {}
storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(prettier@2.8.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -15240,6 +15611,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ swr@2.3.4(react@19.2.4):
+ dependencies:
+ dequal: 2.0.3
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
swr@2.4.1(react@19.2.4):
dependencies:
dequal: 2.0.3