Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-08-24 22:34:35 +08:00
commit 5f14259197
67 changed files with 3307 additions and 380 deletions

View File

@ -23,6 +23,7 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",
@ -85,6 +86,7 @@
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/components/ai-elements/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",

View File

@ -181,6 +181,7 @@ CRON_JOBS_PASSWORD=""
# AI
# https://mksaas.com/docs/ai
# -----------------------------------------------------------------------------
AI_GATEWAY_API_KEY=""
FAL_API_KEY=""
FIREWORKS_API_KEY=""
OPENAI_API_KEY=""

View File

@ -320,6 +320,10 @@
"title": "AI Image",
"description": "Show how to use AI to generate beautiful images"
},
"chat": {
"title": "AI Chat",
"description": "Show how to use AI to chat with your customers"
},
"video": {
"title": "AI Video",
"description": "Show how to use AI to generate amazing videos"
@ -601,8 +605,7 @@
"creditsAdded": "Credits have been added to your account",
"viewTransactions": "View Credit Transactions",
"retry": "Retry",
"expiringCredits": "{credits} credits expiring on {date}"
"expiringCredits": "{credits} credits expiring in the next {days} days"
},
"packages": {
"title": "Credit Packages",
@ -1041,17 +1044,18 @@
},
"AIImagePage": {
"title": "AI Image",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
"content": "Working in progress"
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
},
"AIChatPage": {
"title": "AI Chat",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
},
"AIVideoPage": {
"title": "AI Video",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
"content": "Working in progress"
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
},
"AIAudioPage": {
"title": "AI Audio",
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
"content": "Working in progress"
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
}
}

View File

@ -320,6 +320,10 @@
"title": "AI 图像",
"description": "展示如何使用 AI 生成精美图像"
},
"chat": {
"title": "AI 聊天",
"description": "展示如何使用 AI 与客户聊天"
},
"video": {
"title": "AI 视频",
"description": "展示如何使用 AI 生成惊人视频"
@ -601,8 +605,7 @@
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"retry": "重试",
"expiringCredits": "{credits} 积分将在 {date} 过期"
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
},
"packages": {
"title": "积分套餐",
@ -1041,17 +1044,18 @@
},
"AIImagePage": {
"title": "AI 图片",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
},
"AIChatPage": {
"title": "AI 聊天",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
},
"AIVideoPage": {
"title": "AI 视频",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
},
"AIAudioPage": {
"title": "AI 音频",
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力",
"content": "正在开发中"
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS简单且毫不费力"
}
}

View File

@ -60,6 +60,10 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'html.tailus.io',
},
{
protocol: 'https',
hostname: 'service.firecrawl.dev',
},
],
},
};

View File

@ -31,6 +31,7 @@
"@ai-sdk/fireworks": "^1.0.0",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/react": "^2.0.22",
"@ai-sdk/replicate": "^1.0.0",
"@base-ui-components/react": "1.0.0-beta.0",
"@better-fetch/fetch": "^1.1.18",
@ -75,6 +76,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@react-email/components": "0.0.33",
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
@ -120,6 +122,7 @@
"react-hook-form": "^7.62.0",
"react-remove-scroll": "^2.6.3",
"react-resizable-panels": "^2.1.7",
"react-syntax-highlighter": "^15.6.3",
"react-tweet": "^3.2.2",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.1",
@ -127,6 +130,7 @@
"s3mini": "^0.2.0",
"shiki": "^2.4.2",
"sonner": "^2.0.0",
"streamdown": "^1.0.12",
"stripe": "^17.6.0",
"swiper": "^11.2.5",
"tailwind-merge": "^3.0.2",
@ -134,6 +138,7 @@
"tw-animate-css": "^1.2.4",
"use-intl": "^3.26.5",
"use-media": "^1.5.0",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.0.17",
"zustand": "^5.0.3"
@ -148,6 +153,7 @@
"@types/pg": "^8.11.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"drizzle-kit": "^0.30.4",
"knip": "^5.61.2",
"postcss": "^8",

635
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -48,7 +48,7 @@ export const createCreditCheckoutSession = userActionClient
...metadata,
type: 'credit_purchase',
packageId,
credits: creditPackage.credits.toString(),
credits: creditPackage.amount.toString(),
userId: currentUser.id,
userName: currentUser.name,
};

View File

@ -9,8 +9,19 @@ import { userActionClient } from '@/lib/safe-action';
*/
export const getCreditBalanceAction = userActionClient.action(
async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
const credits = await getUserCredits(currentUser.id);
return { success: true, credits };
try {
const currentUser = (ctx as { user: User }).user;
const credits = await getUserCredits(currentUser.id);
return { success: true, credits };
} catch (error) {
console.error('get credit balance error:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to fetch credit balance',
};
}
}
);

View File

@ -3,11 +3,10 @@
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import type { User } from '@/lib/auth-types';
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
import { userActionClient } from '@/lib/safe-action';
import { addDays } from 'date-fns';
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
const CREDITS_EXPIRATION_DAYS = 31;
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
/**
* Get credit statistics for a user
@ -18,12 +17,14 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
const userId = currentUser.id;
const db = await getDb();
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
const expiringCredits = await db
const now = new Date();
// Get credits expiring in the next 30 days
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
// Get total credits expiring in the next 30 days
const expiringCreditsResult = await db
.select({
amount: sum(creditTransaction.remainingAmount),
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
totalAmount: sum(creditTransaction.remainingAmount),
})
.from(creditTransaction)
.where(
@ -31,18 +32,20 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
eq(creditTransaction.userId, userId),
isNotNull(creditTransaction.expirationDate),
isNotNull(creditTransaction.remainingAmount),
gte(creditTransaction.remainingAmount, 1),
gt(creditTransaction.remainingAmount, 0),
lte(creditTransaction.expirationDate, expirationDaysFromNow),
gte(creditTransaction.expirationDate, new Date())
gte(creditTransaction.expirationDate, now)
)
);
const totalExpiringCredits =
Number(expiringCreditsResult[0]?.totalAmount) || 0;
return {
success: true,
data: {
expiringCredits: {
amount: Number(expiringCredits[0]?.amount) || 0,
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
amount: totalExpiringCredits,
},
},
};

View File

@ -0,0 +1,181 @@
'use client';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Loader } from '@/components/ai-elements/loader';
import { Message, MessageContent } from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputButton,
PromptInputModelSelect,
PromptInputModelSelectContent,
PromptInputModelSelectItem,
PromptInputModelSelectTrigger,
PromptInputModelSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from '@/components/ai-elements/prompt-input';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Response } from '@/components/ai-elements/response';
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from '@/components/ai-elements/source';
import { useChat } from '@ai-sdk/react';
import { GlobeIcon } from 'lucide-react';
import { useState } from 'react';
const models = [
{
name: 'GPT 4o',
value: 'openai/gpt-4o',
},
{
name: 'Deepseek R1',
value: 'deepseek/deepseek-r1',
},
];
export default function ChatBot() {
const [input, setInput] = useState('');
const [model, setModel] = useState<string>(models[0].value);
const [webSearch, setWebSearch] = useState(false);
const { messages, sendMessage, status } = useChat();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
sendMessage(
{ text: input },
{
body: {
model: model,
webSearch: webSearch,
},
}
);
setInput('');
}
};
return (
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
<div className="flex flex-col h-full">
<Conversation className="h-full">
<ConversationContent>
{messages.map((message) => (
<div key={message.id}>
{message.role === 'assistant' && (
<Sources>
{message.parts.map((part, i) => {
switch (part.type) {
case 'source-url':
return (
<>
<SourcesTrigger
count={
message.parts.filter(
(part) => part.type === 'source-url'
).length
}
/>
<SourcesContent key={`${message.id}-${i}`}>
<Source
key={`${message.id}-${i}`}
href={part.url}
title={part.url}
/>
</SourcesContent>
</>
);
}
})}
</Sources>
)}
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return (
<Response key={`${message.id}-${i}`}>
{part.text}
</Response>
);
case 'reasoning':
return (
<Reasoning
key={`${message.id}-${i}`}
className="w-full"
isStreaming={status === 'streaming'}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
default:
return null;
}
})}
</MessageContent>
</Message>
</div>
))}
{status === 'submitted' && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputTextarea
onChange={(e) => setInput(e.target.value)}
value={input}
/>
<PromptInputToolbar>
<PromptInputTools>
<PromptInputButton
variant={webSearch ? 'default' : 'ghost'}
onClick={() => setWebSearch(!webSearch)}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
<PromptInputModelSelect
onValueChange={(value) => {
setModel(value);
}}
value={model}
>
<PromptInputModelSelectTrigger>
<PromptInputModelSelectValue />
</PromptInputModelSelectTrigger>
<PromptInputModelSelectContent>
{models.map((model) => (
<PromptInputModelSelectItem
key={model.value}
value={model.value}
>
{model.name}
</PromptInputModelSelectItem>
))}
</PromptInputModelSelectContent>
</PromptInputModelSelect>
</PromptInputTools>
<PromptInputSubmit disabled={!input} status={status} />
</PromptInputToolbar>
</PromptInput>
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
import Container from '@/components/layout/container';
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button, buttonVariants } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
@ -98,9 +97,6 @@ export default async function AboutPage() {
</div>
</div>
</div>
{/* image section */}
{/* <BlurFadeDemo /> */}
</div>
</Container>
);

View File

@ -0,0 +1,13 @@
import Container from '@/components/layout/container';
import { CreditsTest } from '@/components/test/credits-test';
export default async function TestPage() {
return (
<Container className="py-16 px-4">
<div className="max-w-4xl mx-auto space-y-8">
{/* credits test */}
<CreditsTest />
</div>
</Container>
);
}

View File

@ -42,10 +42,6 @@ export default async function AIAudioPage() {
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,44 @@
import ChatBot from '@/ai/chat/components/ChatBot';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { ZapIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'AIChatPage' });
return constructMetadata({
title: pt('title') + ' | ' + t('title'),
description: pt('description'),
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
});
}
export default async function AIChatPage() {
const t = await getTranslations('AIChatPage');
return (
<div className="min-h-screen bg-muted/50 rounded-lg">
<div className="container mx-auto px-4 py-8 md:py-16">
{/* Header Section */}
<div className="text-center space-y-6 mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
<ZapIcon className="size-4" />
{t('title')}
</div>
</div>
{/* Chat Bot */}
<ChatBot />
</div>
</div>
);
}

View File

@ -26,7 +26,7 @@ export default async function AITextPage() {
const t = await getTranslations('AITextPage');
return (
<div className="min-h-screen bg-background rounded-lg">
<div className="min-h-screen bg-muted/50 rounded-lg">
<div className="container mx-auto px-4 py-8 md:py-16">
{/* Header Section */}
<div className="text-center space-y-6 mb-12">

View File

@ -42,10 +42,6 @@ export default async function AIVideoPage() {
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</div>
</div>
</div>
</div>

View File

@ -212,8 +212,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{relatedPosts && relatedPosts.length > 0 && (
<div className="flex flex-col gap-8 mt-8">
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
<FileTextIcon className="size-4 text-primary" />
<h2 className="text-lg tracking-wider font-semibold text-primary">
{t('morePosts')}
</h2>
</div>

26
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,26 @@
import { type UIMessage, convertToModelMessages, streamText } from 'ai';
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const {
messages,
model,
webSearch,
}: { messages: UIMessage[]; model: string; webSearch: boolean } =
await req.json();
const result = streamText({
model: webSearch ? 'perplexity/sonar' : model,
messages: convertToModelMessages(messages),
system:
'You are a helpful assistant that can answer questions and help with tasks',
});
// send sources and reasoning back to the client
return result.toUIMessageStreamResponse({
sendSources: true,
sendReasoning: true,
});
}

View File

@ -0,0 +1,65 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@ -0,0 +1,212 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(null);
const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error('Branch components must be used within Branch');
}
return context;
};
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View File

@ -0,0 +1,148 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@ -0,0 +1,62 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View File

@ -0,0 +1,287 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
export type InlineCitationProps = ComponentProps<'span'>;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn('group inline items-center gap-1', className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<'span'>;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn('transition-colors group-hover:bg-accent', className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn('w-full space-y-2 p-4 pl-8', className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div
className={cn(
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className
)}
{...props}
>
{children}
</blockquote>
);

View File

@ -0,0 +1,96 @@
import { cn } from '@/lib/utils';
import type { HTMLAttributes } from 'react';
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: 'currentcolor' }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View File

@ -0,0 +1,64 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import type { ComponentProps, HTMLAttributes } from 'react';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
className
)}
{...props}
>
<div className="is-user:dark">{children}</div>
</div>
);
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string;
name?: string;
};
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<Avatar
className={cn('size-8 ring ring-1 ring-border', className)}
{...props}
>
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
);

View File

@ -0,0 +1,230 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { ChatStatus } from 'ai';
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { Children } from 'react';
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
className
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
minHeight?: number;
maxHeight?: number;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
minHeight = 48,
maxHeight = 164,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
// Allow newline
return;
}
// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
};
return (
<Textarea
className={cn(
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View File

@ -0,0 +1,180 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error('Reasoning components must be used within Reasoning');
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = false,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.round((Date.now() - startTime) / 1000));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (isStreaming && !isOpen) {
setIsOpen(true);
} else if (!isStreaming && isOpen && !defaultOpen && !hasAutoClosedRef) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosedRef(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose mb-4', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
title?: string;
};
export const ReasoningTrigger = memo(
({
className,
title = 'Reasoning',
children,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-2 text-muted-foreground text-sm',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{isStreaming || duration === 0 ? (
<p>Thinking...</p>
) : (
<p>Thought for {duration} seconds</p>
)}
<ChevronDownIcon
className={cn(
'size-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View File

@ -0,0 +1,22 @@
'use client';
import { cn } from '@/lib/utils';
import { type ComponentProps, memo } from 'react';
import { Streamdown } from 'streamdown';
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = 'Response';

View File

@ -0,0 +1,74 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BookIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type SourcesProps = ComponentProps<'div'>;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn('not-prose mb-4 text-primary text-xs', className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger className="flex items-center gap-2" {...props}>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<'a'>;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);

View File

@ -0,0 +1,56 @@
'use client';
import { Button } from '@/components/ui/button';
import {
ScrollArea,
ScrollBar,
} from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = 'outline',
size = 'sm',
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion);
};
return (
<Button
className={cn('cursor-pointer rounded-full px-4', className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type TaskItemFileProps = ComponentProps<'div'>;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<'div'>;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View File

@ -0,0 +1,142 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart['type'];
state: ToolUIPart['state'];
className?: string;
};
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
} as const;
const icons = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
<Badge className="rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'flex w-full items-center justify-between gap-4 p-3',
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{type}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<'div'> & {
output: ReactNode;
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground'
)}
>
{errorText && <div>{errorText}</div>}
{output && <div>{output}</div>}
</div>
</div>
);
};

View File

@ -0,0 +1,252 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error('WebPreview components must be used within a WebPreview');
}
return context;
};
export type WebPreviewProps = ComponentProps<'div'> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
export const WebPreview = ({
className,
children,
defaultUrl = '',
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
};
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
'flex size-full flex-col rounded-lg border bg-card',
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<'div'>;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn('flex items-center gap-1 border-b p-2', className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn('size-full', className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: 'log' | 'warn' | 'error';
message: string;
timestamp: Date;
}>;
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground'
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@ -66,7 +66,7 @@ export default function FeaturesSection() {
<div className="grid gap-12 sm:px-12 lg:grid-cols-12 lg:gap-24 lg:px-0">
<div className="lg:col-span-5 flex flex-col gap-8">
<div className="lg:pr-0 text-left">
<h3 className="text-3xl font-semibold lg:text-4xl text-gradient_indigo-purple leading-normal py-1">
<h3 className="text-3xl font-semibold lg:text-4xl text-foreground leading-normal py-1">
{t('title')}
</h3>
<p className="mt-4 text-muted-foreground">{t('description')}</p>

View File

@ -56,14 +56,13 @@ export default function HeroSection() {
<AnimatedGroup variants={transitionVariants}>
<LocaleLink
href={linkIntroduction}
className="hover:bg-background group mx-auto flex w-fit items-center gap-4 rounded-full border p-1 pl-4 shadow-md shadow-zinc-950/5 transition-colors duration-300 dark:shadow-zinc-950"
className="hover:bg-accent group mx-auto flex w-fit items-center gap-2 rounded-full border p-1 pl-4"
>
<span className="text-foreground text-sm">
{t('introduction')}
</span>
{/* <span className="dark:border-background block h-4 w-0.5 border-l bg-white dark:bg-zinc-700"></span> */}
<div className="bg-background group-hover:bg-muted size-6 overflow-hidden rounded-full duration-500">
<div className="size-6 overflow-hidden rounded-full duration-500">
<div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
<span className="flex size-6">
<ArrowRight className="m-auto size-3" />

View File

@ -20,7 +20,7 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
return (
<LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
<div className="group flex flex-col border rounded-lg overflow-hidden h-full">
<div className="group flex flex-col border border-border rounded-lg overflow-hidden h-full transition-all duration-300 ease-in-out hover:border-primary hover:shadow-lg hover:shadow-primary/20">
{/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-16/9 w-full">
{image && (
@ -57,19 +57,7 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
<div className="flex flex-col justify-between p-4 flex-1">
<div>
{/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium">
<span
className="bg-linear-to-r from-green-200 to-green-100
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
transition-[background-size]
duration-500
hover:bg-[length:100%_3px]
group-hover:bg-[length:100%_10px]
dark:from-purple-800 dark:to-purple-900"
>
{title}
</span>
</h3>
<h3 className="text-lg line-clamp-2 font-medium">{title}</h3>
{/* Post excerpt */}
<div className="mt-2">

View File

@ -35,7 +35,13 @@ export function UpgradeCard() {
const isMember =
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
if (!mounted || isLoading || isMember) {
// Ensure the upgrade card is only shown when the data is loaded
if (!mounted || isLoading || !paymentData) {
return null;
}
// If the user is a member, don't show the upgrade card
if (isMember) {
return null;
}

View File

@ -43,7 +43,7 @@ export function HeaderSection({
{title ? (
<TitleComponent
className={cn(
'uppercase tracking-wider text-gradient_indigo-purple font-semibold font-mono',
'uppercase tracking-wider text-primary font-semibold font-mono',
titleClassName
)}
>

View File

@ -37,8 +37,8 @@ const customNavigationMenuTriggerStyle = cn(
'relative bg-transparent text-muted-foreground cursor-pointer',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground',
'data-active:font-semibold data-active:bg-transparent data-active:text-foreground',
'data-[state=open]:bg-transparent data-[state=open]:text-foreground'
'data-active:font-semibold data-active:bg-transparent data-active:text-accent-foreground',
'data-[state=open]:bg-transparent data-[state=open]:text-accent-foreground'
);
export function Navbar({ scroll }: NavBarProps) {
@ -132,10 +132,10 @@ export function Navbar({ scroll }: NavBarProps) {
className={cn(
'flex size-8 shrink-0 items-center justify-center transition-colors',
'bg-transparent text-muted-foreground',
'group-hover:bg-transparent group-hover:text-foreground',
'group-focus:bg-transparent group-focus:text-foreground',
'group-hover:bg-transparent group-hover:text-accent-foreground',
'group-focus:bg-transparent group-focus:text-accent-foreground',
isSubItemActive &&
'bg-transparent text-foreground'
'bg-transparent text-accent-foreground'
)}
>
{subItem.icon ? subItem.icon : null}
@ -144,10 +144,10 @@ export function Navbar({ scroll }: NavBarProps) {
<div
className={cn(
'text-sm font-medium text-muted-foreground',
'group-hover:bg-transparent group-hover:text-foreground',
'group-focus:bg-transparent group-focus:text-foreground',
'group-hover:bg-transparent group-hover:text-accent-foreground',
'group-focus:bg-transparent group-focus:text-accent-foreground',
isSubItemActive &&
'bg-transparent text-foreground'
'bg-transparent text-accent-foreground'
)}
>
{subItem.title}
@ -156,10 +156,10 @@ export function Navbar({ scroll }: NavBarProps) {
<div
className={cn(
'text-sm text-muted-foreground',
'group-hover:bg-transparent group-hover:text-foreground/80',
'group-focus:bg-transparent group-focus:text-foreground/80',
'group-hover:bg-transparent group-hover:text-accent-foreground/80',
'group-focus:bg-transparent group-focus:text-accent-foreground/80',
isSubItemActive &&
'bg-transparent text-foreground/80'
'bg-transparent text-accent-foreground/80'
)}
>
{subItem.description}
@ -170,10 +170,10 @@ export function Navbar({ scroll }: NavBarProps) {
<ArrowUpRightIcon
className={cn(
'size-4 shrink-0 text-muted-foreground',
'group-hover:bg-transparent group-hover:text-foreground',
'group-focus:bg-transparent group-focus:text-foreground',
'group-hover:bg-transparent group-hover:text-accent-foreground',
'group-focus:bg-transparent group-focus:text-accent-foreground',
isSubItemActive &&
'bg-transparent text-foreground'
'bg-transparent text-accent-foreground'
)}
/>
)}

View File

@ -100,7 +100,7 @@ export default function BillingCard() {
// Render loading skeleton if not mounted or in a loading state
const isPageLoading = isLoadingPayment || isLoadingSession;
if (!mounted || isPageLoading) {
if (!mounted || isPageLoading || !paymentData) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
@ -111,14 +111,14 @@ export default function BillingCard() {
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="flex items-center justify-start space-x-4">
<Skeleton className="h-6 w-1/5" />
<Skeleton className="h-8 w-1/5" />
</div>
<div className="text-sm text-muted-foreground space-y-2">
<Skeleton className="h-6 w-3/5" />
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Skeleton className="h-10 w-1/2" />
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Skeleton className="h-8 w-1/4" />
</CardFooter>
</Card>
);
@ -139,7 +139,7 @@ export default function BillingCard() {
{loadPaymentError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button
variant="outline"
className="cursor-pointer"
@ -262,7 +262,7 @@ export default function BillingCard() {
</div>
)}
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
{/* user is on free plan, show upgrade plan button */}
{isFreePlan && (
<Button variant="default" className="cursor-pointer" asChild>

View File

@ -41,11 +41,11 @@ export function CreditPackages() {
(pkg) => !pkg.disabled && pkg.price.priceId
);
// Check if user is on free plan and enableForFreePlan is false
// Check if user is on free plan and enablePackagesForFreePlan is false
const isFreePlan = currentPlan?.isFree === true;
// Check if user is on free plan and enableForFreePlan is false
if (isFreePlan && !websiteConfig.credits.enableForFreePlan) {
// Check if user is on free plan and enablePackagesForFreePlan is false
if (isFreePlan && !websiteConfig.credits.enablePackagesForFreePlan) {
return null;
}
@ -84,7 +84,7 @@ export function CreditPackages() {
<div className="text-left">
<div className="text-2xl font-semibold flex items-center gap-2">
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
{creditPackage.credits.toLocaleString()}
{creditPackage.amount.toLocaleString()}
</div>
</div>
<div className="text-right">

View File

@ -14,7 +14,7 @@ import { websiteConfig } from '@/config/website';
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits';
import { useMounted } from '@/hooks/use-mounted';
import { useLocaleRouter } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter';
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { RefreshCwIcon } from 'lucide-react';
@ -102,13 +102,12 @@ export default function CreditsBalanceCard() {
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="flex items-center justify-start space-x-4">
<Skeleton className="h-6 w-1/5" />
</div>
<div className="text-sm text-muted-foreground space-y-2">
<Skeleton className="h-6 w-3/5" />
<Skeleton className="h-8 w-1/5" />
</div>
</CardContent>
<CardFooter className="">{/* show nothing */}</CardFooter>
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
<Skeleton className="h-6 w-3/5" />
</CardFooter>
</Card>
);
}
@ -126,7 +125,7 @@ export default function CreditsBalanceCard() {
{balanceError?.message || statsError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button
variant="outline"
className="cursor-pointer"
@ -146,8 +145,8 @@ export default function CreditsBalanceCard() {
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Credits balance display */}
<CardContent className="flex-1">
{/* Credits balance */}
<div className="flex items-center justify-start space-x-4">
<div className="flex items-center space-x-2">
{/* <CoinsIcon className="h-6 w-6 text-muted-foreground" /> */}
@ -157,33 +156,22 @@ export default function CreditsBalanceCard() {
</div>
{/* <Badge variant="outline">available</Badge> */}
</div>
{/* Balance information */}
<div className="text-sm text-muted-foreground space-y-2">
{/* Expiring credits warning */}
{!isLoadingStats &&
creditStats &&
creditStats.expiringCredits.amount > 0 &&
creditStats.expiringCredits.earliestExpiration && (
<div className="flex items-center gap-2 text-amber-600">
<span>
{t('expiringCredits', {
credits: creditStats.expiringCredits.amount,
date: formatDate(
new Date(creditStats.expiringCredits.earliestExpiration)
),
})}
</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="">
{/* <Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.SettingsCredits}>
{t('viewTransactions')}
</LocaleLink>
</Button> */}
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
{/* Expiring credits warning */}
{!isLoadingStats && creditStats && (
<div className="text-sm text-muted-foreground space-y-2">
{' '}
<div className="flex items-center gap-2 text-amber-600">
<span>
{t('expiringCredits', {
credits: creditStats.expiringCredits.amount,
days: CREDITS_EXPIRATION_DAYS,
})}
</span>
</div>
</div>
)}
</CardFooter>
</Card>
);

View File

@ -173,7 +173,7 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
}
/>
</CardContent>
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
<CardFooter className="mt-6 px-6 py-4 bg-muted rounded-none">
<p className="text-sm text-muted-foreground">
{t('newsletter.hint')}
</p>

View File

@ -171,7 +171,7 @@ export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) {
<FormError message={error} />
</CardContent>
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-background rounded-none">
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-muted rounded-none">
<p className="text-sm text-muted-foreground">
{t('avatar.recommendation')}
</p>

View File

@ -140,7 +140,7 @@ export function UpdateNameCard({ className }: UpdateNameCardProps) {
/>
<FormError message={error} />
</CardContent>
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
<p className="text-sm text-muted-foreground">{t('name.hint')}</p>
<Button

View File

@ -97,7 +97,7 @@ export function DeleteAccountCard() {
</div>
)}
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button
variant="destructive"
onClick={() => setShowConfirmation(true)}

View File

@ -6,6 +6,7 @@ import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
@ -57,7 +58,7 @@ export function PasswordCardWrapper() {
function PasswordSkeletonCard() {
const t = useTranslations('Dashboard.settings.security.updatePassword');
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-6 flex flex-col')}>
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
@ -67,9 +68,10 @@ function PasswordSkeletonCard() {
<Skeleton className="h-6 w-full" />
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-6 w-full" />
</CardContent>
<CardFooter className="px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Skeleton className="h-8 w-1/4" />
</CardFooter>
</Card>
);
}

View File

@ -66,7 +66,7 @@ export function ResetPasswordCard({ className }: ResetPasswordCardProps) {
<CardContent className="space-y-4 flex-1">
<p className="text-sm text-muted-foreground">{t('info')}</p>
</CardContent>
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-background rounded-none">
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button onClick={handleSetupPassword} className="cursor-pointer">
{t('button')}
</Button>

View File

@ -206,7 +206,7 @@ export function UpdatePasswordCard({ className }: UpdatePasswordCardProps) {
/>
<FormError message={error} />
</CardContent>
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
<p className="text-sm text-muted-foreground">{t('hint')}</p>
<Button

View File

@ -0,0 +1,53 @@
'use client';
import { Button } from '@/components/ui/button';
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
import { CoinsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
export function CreditsTest() {
const { data: balance = 0, isLoading } = useCreditBalance();
const consumeCreditsMutation = useConsumeCredits();
const [loading, setLoading] = useState(false);
const handleConsume = async () => {
setLoading(true);
try {
await consumeCreditsMutation.mutateAsync({
amount: 10,
description: 'Test credit consumption',
});
toast.success('10 credits consumed successfully!');
} catch (error) {
toast.error('Failed to consume credits');
} finally {
setLoading(false);
}
};
return (
<div className="p-4 border rounded-lg space-y-4">
<h3 className="text-lg font-semibold">Credits Store Test</h3>
<div className="space-y-2">
<p>
<strong>Store Balance:</strong> {balance}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleConsume}
disabled={loading || consumeCreditsMutation.isPending}
size="sm"
>
<CoinsIcon className="w-4 h-4 mr-2" />
Consume 10 Credits
</Button>
</div>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
</div>
);
}

View File

@ -14,7 +14,7 @@ const badgeVariants = cva(
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70",
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},

View File

@ -18,7 +18,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>

View File

@ -20,6 +20,7 @@ import {
LogInIcon,
MailIcon,
MailboxIcon,
MessageCircleIcon,
NewspaperIcon,
RocketIcon,
ShieldCheckIcon,
@ -95,6 +96,13 @@ export function getNavbarLinks(): NestedMenuItem[] {
href: Routes.AIImage,
external: false,
},
{
title: t('ai.items.chat.title'),
description: t('ai.items.chat.description'),
icon: <MessageCircleIcon className="size-4 shrink-0" />,
href: Routes.AIChat,
external: false,
},
// {
// title: t('ai.items.video.title'),
// description: t('ai.items.video.description'),

View File

@ -155,17 +155,17 @@ export const websiteConfig: WebsiteConfig = {
},
credits: {
enableCredits: process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true',
enableForFreePlan: false,
enablePackagesForFreePlan: false,
registerGiftCredits: {
enable: true,
credits: 50,
amount: 50,
expireDays: 30,
},
packages: {
basic: {
id: 'basic',
popular: false,
credits: 100,
amount: 100,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC!,
@ -177,7 +177,7 @@ export const websiteConfig: WebsiteConfig = {
standard: {
id: 'standard',
popular: true,
credits: 200,
amount: 200,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!,
@ -189,7 +189,7 @@ export const websiteConfig: WebsiteConfig = {
premium: {
id: 'premium',
popular: false,
credits: 500,
amount: 500,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!,
@ -201,7 +201,7 @@ export const websiteConfig: WebsiteConfig = {
enterprise: {
id: 'enterprise',
popular: false,
credits: 1000,
amount: 1000,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!,

View File

@ -2,9 +2,9 @@ import { randomUUID } from 'crypto';
import { websiteConfig } from '@/config/website';
import { getDb } from '@/db';
import { creditTransaction, userCredit } from '@/db/schema';
import { findPlanByPriceId } from '@/lib/price-plan';
import { findPlanByPlanId, findPlanByPriceId } from '@/lib/price-plan';
import { addDays, isAfter } from 'date-fns';
import { and, asc, eq, gt, isNull, not, or } from 'drizzle-orm';
import { and, asc, eq, gt, isNull, not, or, sql } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
@ -49,23 +49,6 @@ export async function updateUserCredits(userId: string, credits: number) {
}
}
/**
* Update user's last refresh time
* @param userId - User ID
* @param date - Last refresh time
*/
export async function updateUserLastRefreshAt(userId: string, date: Date) {
try {
const db = await getDb();
await db
.update(userCredit)
.set({ lastRefreshAt: date, updatedAt: new Date() })
.where(eq(userCredit.userId, userId));
} catch (error) {
console.error('updateUserLastRefreshAt, error:', error);
}
}
/**
* Write a credit transaction record
* @param params - Credit transaction parameters
@ -164,7 +147,6 @@ export async function addCredits({
.update(userCredit)
.set({
currentCredits: newBalance,
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
updatedAt: new Date(),
})
.where(eq(userCredit.userId, userId));
@ -175,7 +157,6 @@ export async function addCredits({
id: randomUUID(),
userId,
currentCredits: newBalance,
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
createdAt: new Date(),
updatedAt: new Date(),
});
@ -375,34 +356,39 @@ export async function processExpiredCredits(userId: string) {
}
/**
* Check if credits can be added for a user based on last refresh time
* Check if specific type of credits can be added for a user based on transaction history
* @param userId - User ID
* @param creditType - Type of credit transaction to check
*/
export async function canAddMonthlyCredits(userId: string) {
export async function canAddCreditsByType(userId: string, creditType: string) {
const db = await getDb();
const record = await db
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
// Check if user has already received this type of credits this month
const existingTransaction = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(creditTransaction.type, creditType),
// Check if transaction was created in the current month and year
sql`EXTRACT(MONTH FROM ${creditTransaction.createdAt}) = ${currentMonth + 1}`,
sql`EXTRACT(YEAR FROM ${creditTransaction.createdAt}) = ${currentYear}`
)
)
.limit(1);
const now = new Date();
let canAdd = false;
// Check if user has never received credits or it's a new month
if (!record[0]?.lastRefreshAt) {
canAdd = true;
} else {
// different month or year means new month
const last = new Date(record[0].lastRefreshAt);
canAdd =
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear();
}
return canAdd;
return existingTransaction.length === 0;
}
/**
* Check if subscription credits can be added for a user based on last refresh time
* @param userId - User ID
*/
/**
* Add register gift credits
* @param userId - User ID
@ -423,7 +409,7 @@ export async function addRegisterGiftCredits(userId: string) {
// add register gift credits if user has not received them yet
if (record.length === 0) {
const credits = websiteConfig.credits.registerGiftCredits.credits;
const credits = websiteConfig.credits.registerGiftCredits.amount;
const expireDays = websiteConfig.credits.registerGiftCredits.expireDays;
await addCredits({
userId,
@ -442,11 +428,11 @@ export async function addRegisterGiftCredits(userId: string) {
/**
* Add free monthly credits
* @param userId - User ID
* @param priceId - Price ID
* @param planId - Plan ID
*/
export async function addMonthlyFreeCredits(userId: string, priceId: string) {
export async function addMonthlyFreeCredits(userId: string, planId: string) {
// NOTICE: make sure the free plan is not disabled and has credits enabled
const pricePlan = findPlanByPriceId(priceId);
const pricePlan = findPlanByPlanId(planId);
if (
!pricePlan ||
pricePlan.disabled ||
@ -455,12 +441,15 @@ export async function addMonthlyFreeCredits(userId: string, priceId: string) {
!pricePlan.credits.enable
) {
console.log(
`addMonthlyFreeCredits, no credits configured for plan ${priceId}`
`addMonthlyFreeCredits, no credits configured for plan ${planId}`
);
return;
}
const canAdd = await canAddMonthlyCredits(userId);
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH
);
const now = new Date();
// add credits if it's a new month
@ -475,9 +464,6 @@ export async function addMonthlyFreeCredits(userId: string, priceId: string) {
expireDays,
});
// Update last refresh time for free monthly credits
await updateUserLastRefreshAt(userId, now);
console.log(
`addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
@ -508,7 +494,10 @@ export async function addSubscriptionCredits(userId: string, priceId: string) {
return;
}
const canAdd = await canAddMonthlyCredits(userId);
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
);
const now = new Date();
// Add credits if it's a new month
@ -524,9 +513,6 @@ export async function addSubscriptionCredits(userId: string, priceId: string) {
expireDays,
});
// Update last refresh time for subscription credits
await updateUserLastRefreshAt(userId, now);
console.log(
`addSubscriptionCredits, ${credits} credits for user ${userId}, priceId: ${priceId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
@ -561,7 +547,10 @@ export async function addLifetimeMonthlyCredits(
return;
}
const canAdd = await canAddMonthlyCredits(userId);
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY
);
const now = new Date();
// Add credits if it's a new month
@ -577,9 +566,6 @@ export async function addLifetimeMonthlyCredits(
expireDays,
});
// Update last refresh time for lifetime credits
await updateUserLastRefreshAt(userId, now);
console.log(
`addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);

View File

@ -5,6 +5,7 @@ import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { PlanIntervals } from '@/payment/types';
import { addDays } from 'date-fns';
import { and, eq, gt, inArray, isNull, lt, not, or, sql } from 'drizzle-orm';
import { canAddCreditsByType } from './credits';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
@ -218,7 +219,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
const userCredits = await tx
.select({
userId: userCredit.userId,
lastRefreshAt: userCredit.lastRefreshAt,
currentCredits: userCredit.currentCredits,
})
.from(userCredit)
@ -229,19 +229,17 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
userCredits.map((record) => [record.userId, record])
);
// Filter users who can receive credits
const eligibleUserIds = userIds.filter((userId) => {
const record = userCreditMap.get(userId);
if (!record?.lastRefreshAt) {
return true; // never added credits before
}
// different month or year means new month
const last = new Date(record.lastRefreshAt);
return (
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
// Check which users can receive credits based on transaction history
const eligibleUserIds: string[] = [];
for (const userId of userIds) {
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH
);
});
if (canAdd) {
eligibleUserIds.push(userId);
}
}
if (eligibleUserIds.length === 0) {
console.log('batchAddMonthlyFreeCredits, no eligible users');
@ -280,7 +278,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
id: randomUUID(),
userId,
currentCredits: credits,
lastRefreshAt: now,
createdAt: now,
updatedAt: now,
}));
@ -297,7 +294,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
.update(userCredit)
.set({
currentCredits: newBalance,
lastRefreshAt: now,
updatedAt: now,
})
.where(eq(userCredit.userId, userId));
@ -362,7 +358,6 @@ export async function batchAddLifetimeMonthlyCredits(
const userCredits = await tx
.select({
userId: userCredit.userId,
lastRefreshAt: userCredit.lastRefreshAt,
currentCredits: userCredit.currentCredits,
})
.from(userCredit)
@ -373,19 +368,17 @@ export async function batchAddLifetimeMonthlyCredits(
userCredits.map((record) => [record.userId, record])
);
// Filter users who can receive credits
const eligibleUserIds = userIdsForPrice.filter((userId: string) => {
const record = userCreditMap.get(userId);
if (!record?.lastRefreshAt) {
return true; // never added credits before
}
// different month or year means new month
const last = new Date(record.lastRefreshAt);
return (
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
// Check which users can receive credits based on transaction history
const eligibleUserIds: string[] = [];
for (const userId of userIdsForPrice) {
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY
);
});
if (canAdd) {
eligibleUserIds.push(userId);
}
}
if (eligibleUserIds.length === 0) {
console.log(
@ -426,7 +419,6 @@ export async function batchAddLifetimeMonthlyCredits(
id: randomUUID(),
userId,
currentCredits: credits,
lastRefreshAt: now,
createdAt: now,
updatedAt: now,
}));
@ -443,7 +435,6 @@ export async function batchAddLifetimeMonthlyCredits(
.update(userCredit)
.set({
currentCredits: newBalance,
lastRefreshAt: now,
updatedAt: now,
})
.where(eq(userCredit.userId, userId));
@ -508,7 +499,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
const userCredits = await tx
.select({
userId: userCredit.userId,
lastRefreshAt: userCredit.lastRefreshAt,
currentCredits: userCredit.currentCredits,
})
.from(userCredit)
@ -519,19 +509,17 @@ export async function batchAddYearlyUsersMonthlyCredits(
userCredits.map((record) => [record.userId, record])
);
// Filter users who can receive credits
const eligibleUserIds = userIds.filter((userId) => {
const record = userCreditMap.get(userId);
if (!record?.lastRefreshAt) {
return true; // never added credits before
}
// different month or year means new month
const last = new Date(record.lastRefreshAt);
return (
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
// Check which users can receive credits based on transaction history
const eligibleUserIds: string[] = [];
for (const userId of userIds) {
const canAdd = await canAddCreditsByType(
userId,
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
);
});
if (canAdd) {
eligibleUserIds.push(userId);
}
}
if (eligibleUserIds.length === 0) {
console.log(
@ -572,7 +560,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
id: randomUUID(),
userId,
currentCredits: credits,
lastRefreshAt: now,
createdAt: now,
updatedAt: now,
}));
@ -589,7 +576,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
.update(userCredit)
.set({
currentCredits: newBalance,
lastRefreshAt: now,
updatedAt: now,
})
.where(eq(userCredit.userId, userId));

View File

@ -26,7 +26,7 @@ export interface CreditPackagePrice {
*/
export interface CreditPackage {
id: string; // Unique identifier for the package
credits: number; // Number of credits in the package
amount: number; // Amount of credits in the package
price: CreditPackagePrice; // Price of the package
popular: boolean; // Whether the package is popular
name?: string; // Display name of the package

View File

@ -94,7 +94,7 @@ export const userCredit = pgTable("user_credit", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }),
currentCredits: integer("current_credits").notNull().default(0),
lastRefreshAt: timestamp("last_refresh_at"),
lastRefreshAt: timestamp("last_refresh_at"), // deprecated
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (table) => ({

View File

@ -2,8 +2,10 @@ import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import { useCreditsStore } from '@/stores/credits-store';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { useEffect } from 'react';
// Query keys
export const creditsKeys = {
@ -21,23 +23,39 @@ export const creditsKeys = {
// Hook to fetch credit balance
export function useCreditBalance() {
return useQuery({
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
const query = useQuery({
queryKey: creditsKeys.balance(),
queryFn: async () => {
console.log('Fetching credit balance...');
const result = await getCreditBalanceAction();
if (!result?.data?.success) {
throw new Error('Failed to fetch credit balance');
throw new Error(
result?.data?.error || 'Failed to fetch credit balance'
);
}
console.log('Credit balance fetched:', result.data.credits);
return result.data.credits || 0;
},
});
// Refetch when updateTrigger changes
useEffect(() => {
if (updateTrigger > 0) {
console.log('Credits update triggered, refetching balance...');
query.refetch();
}
}, [updateTrigger, query]);
return query;
}
// Hook to fetch credit statistics
export function useCreditStats() {
return useQuery({
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
const query = useQuery({
queryKey: creditsKeys.stats(),
queryFn: async () => {
console.log('Fetching credit stats...');
@ -49,11 +67,22 @@ export function useCreditStats() {
return result.data.data;
},
});
// Refetch when updateTrigger changes
useEffect(() => {
if (updateTrigger > 0) {
console.log('Credits update triggered, refetching stats...');
query.refetch();
}
}, [updateTrigger, query]);
return query;
}
// Hook to consume credits
export function useConsumeCredits() {
const queryClient = useQueryClient();
const triggerUpdate = useCreditsStore((state) => state.triggerUpdate);
return useMutation({
mutationFn: async ({
@ -73,6 +102,9 @@ export function useConsumeCredits() {
return result.data;
},
onSuccess: () => {
// Trigger credits update in store to notify all components
triggerUpdate();
// Invalidate credit balance and stats after consuming credits
queryClient.invalidateQueries({
queryKey: creditsKeys.balance(),

View File

@ -190,7 +190,7 @@ async function onCreateUser(user: User) {
if (
websiteConfig.credits.enableCredits &&
websiteConfig.credits.registerGiftCredits.enable &&
websiteConfig.credits.registerGiftCredits.credits > 0
websiteConfig.credits.registerGiftCredits.amount > 0
) {
try {
await addRegisterGiftCredits(user.id);

View File

@ -1,2 +1,10 @@
/**
* in next 30 days for credits expiration
*/
export const CREDITS_EXPIRATION_DAYS = 30;
/**
* placeholder image for blog post card
*/
export const PLACEHOLDER_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg==';

View File

@ -40,6 +40,7 @@ export enum Routes {
// AI routes
AIText = '/ai/text',
AIImage = '/ai/image',
AIChat = '/ai/chat',
AIVideo = '/ai/video',
AIAudio = '/ai/audio',

View File

@ -0,0 +1,23 @@
import { create } from 'zustand';
interface CreditsState {
// trigger for credit updates, incremented each time credits change
updateTrigger: number;
// method to trigger credit updates
triggerUpdate: () => void;
}
/**
* Credits store for managing credit balance updates.
*
* This store provides a simple trigger mechanism to notify components
* when credits have been consumed or updated, ensuring UI components can
* refetch the latest credit balance.
*/
export const useCreditsStore = create<CreditsState>((set) => ({
updateTrigger: 0,
triggerUpdate: () =>
set((state) => ({
updateTrigger: state.updateTrigger + 1,
})),
}));

View File

@ -12,7 +12,8 @@
* https://tweakcn.com/
* https://ui.pub/x/theme-gen
*
* default theme: Clean Slate
* default theme: custom theme inspired by Qoder
* https://qoder.com/
* https://tweakcn.com/editor/theme
*
* NOTICE: when you change the theme, you need to check the fonts and keep the animation variables
@ -186,107 +187,107 @@
}
:root {
--background: oklch(0.98 0.0 247.86);
--foreground: oklch(0.28 0.04 260.03);
--background: oklch(1.0 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0 0 0);
--card-foreground: oklch(0.28 0.04 260.03);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0 0 0);
--popover-foreground: oklch(0.28 0.04 260.03);
--primary: oklch(0.59 0.2 277.12);
--primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.93 0.01 264.53);
--secondary-foreground: oklch(0.37 0.03 259.73);
--muted: oklch(0.97 0.0 264.54);
--muted-foreground: oklch(0.55 0.02 264.36);
--accent: oklch(0.93 0.03 272.79);
--accent-foreground: oklch(0.37 0.03 259.73);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.87 0.01 258.34);
--input: oklch(0.87 0.01 258.34);
--ring: oklch(0.59 0.2 277.12);
--chart-1: oklch(0.59 0.2 277.12);
--chart-2: oklch(0.51 0.23 276.97);
--chart-3: oklch(0.46 0.21 277.02);
--chart-4: oklch(0.4 0.18 277.37);
--chart-5: oklch(0.36 0.14 278.7);
--sidebar: oklch(0.97 0.0 264.54);
--sidebar-foreground: oklch(0.28 0.04 260.03);
--sidebar-primary: oklch(0.59 0.2 277.12);
--sidebar-primary-foreground: oklch(1.0 0 0);
--sidebar-accent: oklch(0.93 0.03 272.79);
--sidebar-accent-foreground: oklch(0.37 0.03 259.73);
--sidebar-border: oklch(0.87 0.01 258.34);
--sidebar-ring: oklch(0.59 0.2 277.12);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.6271 0.1699 149.2138);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5555 0 0);
--accent: oklch(0.9819 0.0181 155.8263);
--accent-foreground: oklch(0.4479 0.1083 151.3277);
--destructive: oklch(0.5771 0.2152 27.325);
--destructive-foreground: oklch(0.9851 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7227 0.192 149.5793);
--chart-1: oklch(0.7227 0.192 149.5793);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.1448 0 0);
--chart-4: oklch(0.7155 0 0);
--chart-5: oklch(0.9219 0 0);
--sidebar: oklch(0.9702 0 0);
--sidebar-foreground: oklch(0.5555 0 0);
--sidebar-primary: oklch(0.6271 0.1699 149.2138);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9819 0.0181 155.8263);
--sidebar-accent-foreground: oklch(0.4479 0.1083 151.3277);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7227 0.192 149.5793);
--font-sans: var(--font-noto-sans);
--font-serif: var(--font-noto-serif);
--font-mono: var(--font-noto-sans-mono);
--radius: 0.5rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--shadow-2xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.03);
--shadow-xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.03);
--shadow-sm: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px
hsl(0 0% 0% / 0.05);
--shadow: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px
hsl(0 0% 0% / 0.05);
--shadow-md: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px
hsl(0 0% 0% / 0.05);
--shadow-lg: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px
hsl(0 0% 0% / 0.05);
--shadow-xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px
hsl(0 0% 0% / 0.05);
--shadow-2xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.13);
}
.dark {
--background: oklch(0.21 0.04 265.75);
--foreground: oklch(0.93 0.01 255.51);
--card: oklch(0.28 0.04 260.03);
--card-foreground: oklch(0.93 0.01 255.51);
--popover: oklch(0.28 0.04 260.03);
--popover-foreground: oklch(0.93 0.01 255.51);
--primary: oklch(0.68 0.16 276.93);
--primary-foreground: oklch(0.21 0.04 265.75);
--secondary: oklch(0.34 0.03 260.91);
--secondary-foreground: oklch(0.87 0.01 258.34);
--muted: oklch(0.28 0.04 260.03);
--muted-foreground: oklch(0.71 0.02 261.32);
--accent: oklch(0.37 0.03 259.73);
--accent-foreground: oklch(0.87 0.01 258.34);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(0.21 0.04 265.75);
--border: oklch(0.45 0.03 256.8);
--input: oklch(0.45 0.03 256.8);
--ring: oklch(0.68 0.16 276.93);
--chart-1: oklch(0.68 0.16 276.93);
--chart-2: oklch(0.59 0.2 277.12);
--chart-3: oklch(0.51 0.23 276.97);
--chart-4: oklch(0.46 0.21 277.02);
--chart-5: oklch(0.4 0.18 277.37);
--sidebar: oklch(0.28 0.04 260.03);
--sidebar-foreground: oklch(0.93 0.01 255.51);
--sidebar-primary: oklch(0.68 0.16 276.93);
--sidebar-primary-foreground: oklch(0.21 0.04 265.75);
--sidebar-accent: oklch(0.37 0.03 259.73);
--sidebar-accent-foreground: oklch(0.87 0.01 258.34);
--sidebar-border: oklch(0.45 0.03 256.8);
--sidebar-ring: oklch(0.68 0.16 276.93);
--background: oklch(0.1591 0 0);
--foreground: oklch(0.9702 0 0);
--card: oklch(0.2046 0 0);
--card-foreground: oklch(0.9702 0 0);
--popover: oklch(0.2046 0 0);
--popover-foreground: oklch(0.9702 0 0);
--primary: oklch(0.8003 0.1821 151.711);
--primary-foreground: oklch(0.1591 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.2638 0.0276 154.8977);
--accent-foreground: oklch(0.8003 0.1821 151.711);
--destructive: oklch(0.3958 0.1331 25.723);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.4 0 0);
--input: oklch(0.4 0 0);
--ring: oklch(0.8003 0.1821 151.711);
--chart-1: oklch(0.8003 0.1821 151.711);
--chart-2: oklch(0.7155 0 0);
--chart-3: oklch(0.9702 0 0);
--chart-4: oklch(0.4386 0 0);
--chart-5: oklch(0.2686 0 0);
--sidebar: oklch(0.22 0 0);
--sidebar-foreground: oklch(0.7155 0 0);
--sidebar-primary: oklch(0.8003 0.1821 151.711);
--sidebar-primary-foreground: oklch(0.1591 0 0);
--sidebar-accent: oklch(0.2638 0.0276 154.8977);
--sidebar-accent-foreground: oklch(0.8003 0.1821 151.711);
--sidebar-border: oklch(0.2686 0 0);
--sidebar-ring: oklch(0.8003 0.1821 151.711);
--font-sans: var(--font-noto-sans);
--font-serif: var(--font-noto-serif);
--font-mono: var(--font-noto-sans-mono);
--radius: 0.5rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
--shadow-2xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
--shadow: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px
--shadow-md: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px
--shadow-lg: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px
--shadow-xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--shadow-2xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.25);
}
/*

View File

@ -169,10 +169,10 @@ export interface PriceConfig {
*/
export interface CreditsConfig {
enableCredits: boolean; // Whether to enable credits
enableForFreePlan: boolean; // Whether to enable purchase credits for free plan users
enablePackagesForFreePlan: boolean;// Whether to enable purchase credits for free plan users
registerGiftCredits: {
enable: boolean; // Whether to enable register gift credits
credits: number; // The number of credits to give to the user
amount: number; // The amount of credits to give to the user
expireDays?: number; // The number of days to expire the credits, undefined means no expire
};
packages: Record<string, CreditPackage>; // Packages indexed by ID