diff --git a/package.json b/package.json index 5931345c..d746a39b 100644 --- a/package.json +++ b/package.json @@ -186,5 +186,10 @@ "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", "vitest": "^4.1.2" + }, + "pnpm": { + "patchedDependencies": { + "autumn-js@1.2.9": "patches/autumn-js@1.2.9.patch" + } } } diff --git a/patches/autumn-js@1.2.9.patch b/patches/autumn-js@1.2.9.patch new file mode 100644 index 00000000..9dcb01bd --- /dev/null +++ b/patches/autumn-js@1.2.9.patch @@ -0,0 +1,13 @@ +diff --git a/dist/better-auth/chunk-LRULDM7S.mjs b/dist/better-auth/chunk-LRULDM7S.mjs +index 31334ee65452ccbf47388e1d4cd79ce7d1f908ce..4e24d0ed9013cd9968df667738d47aba98afd2e2 100644 +--- a/dist/better-auth/chunk-LRULDM7S.mjs ++++ b/dist/better-auth/chunk-LRULDM7S.mjs +@@ -6,7 +6,7 @@ import { + } from "./chunk-GJAMWZNZ.mjs"; + + // src/better-auth/utils/createAutumnEndpoint.ts +-import { createAuthEndpoint } from "better-auth/plugins"; ++import { createAuthEndpoint } from "better-auth/api"; + var getRouteConfig = (routeName) => { + const route = routeConfigs.find((r) => r.route === routeName); + if (!route) throw new Error(`Route not found: ${routeName}`); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..c1aabe54 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +patchedDependencies: + autumn-js@1.2.9: patches/autumn-js@1.2.9.patch diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 53e7dc4f..094e317a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -21,7 +21,11 @@ import { import { withServerObservability } from "@/lib/with-server-observability"; import { normalizeLegacyToolMessages } from "@/lib/ai/legacy-tool-message-compat"; import type { ReplySelection } from "@/lib/stores/ui-store"; -import { getDefaultChatModelId, resolveGatewayModelId } from "@/lib/ai/models"; +import { + getDefaultChatModelId, + isPremiumChatModel, + resolveGatewayModelId, +} from "@/lib/ai/models"; import { buildGatewayProviderOptions, createGatewayLanguageModel, @@ -55,6 +59,17 @@ function getSelectedCardsContext(body: any): string { return body.selectedCardsContext || ""; } +type AutumnBillingAPI = { + check: (args: { + headers: Awaited>; + body: { featureId: string }; + }) => Promise<{ allowed: boolean }>; + track: (args: { + headers: Awaited>; + body: { featureId: string; value: number }; + }) => Promise; +}; + /** * Inject user-selected context (selected cards + reply quotes / workspace passages) into the last user message. * `custom` is body.metadata.custom from the composer's runConfig (replySelections only). @@ -123,9 +138,11 @@ async function handlePOST(req: Request) { try { // FIX: Parallelize headers() and req.json() to eliminate waterfall const [headersObj, body] = await Promise.all([headers(), req.json()]); + const autumnBilling = auth.api as typeof auth.api & AutumnBillingAPI; // Get authenticated user ID const session = await auth.api.getSession({ headers: headersObj }); + const isAnonymousUser = Boolean(session?.user?.isAnonymous); userId = session?.user?.id || null; const { messages = [] }: { messages?: UIMessage[] } = body; @@ -188,9 +205,8 @@ async function handlePOST(req: Request) { // Get pre-formatted selected cards context from client (no DB fetch needed) const selectedCardsContext = getSelectedCardsContext(body); - const modelId = resolveGatewayModelId( - body.modelId || getDefaultChatModelId(), - ); + const rawModelId = body.modelId || getDefaultChatModelId(); + const modelId = resolveGatewayModelId(rawModelId); // Inject selected cards + reply selections into the last user message injectSelectionContext( @@ -199,6 +215,32 @@ async function handlePOST(req: Request) { selectedCardsContext, ); + if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) { + try { + const checkResult = await autumnBilling.check({ + headers: headersObj, + body: { featureId: "premium_message" }, + }); + + if (!checkResult.allowed) { + return new Response( + JSON.stringify({ + error: "credits_exhausted", + message: + "You've used all your premium AI messages for this month. Upgrade to Pro for unlimited access, or switch to another model.", + code: "CREDITS_EXHAUSTED", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + } catch (err) { + console.error("[billing] Autumn check failed, allowing request:", err); + } + } + const posthogClient = getPostHogServerClient(); const baseGatewayModel = createGatewayLanguageModel(modelId); const tracedModel = posthogClient @@ -245,7 +287,7 @@ async function handlePOST(req: Request) { }, }, experimental_transform: smoothStream({ chunking: "word", delayInMs: 15 }), - onFinish: ({ usage, finishReason }) => { + onFinish: async ({ usage, finishReason }) => { const usageInfo = { inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens, @@ -257,6 +299,17 @@ async function handlePOST(req: Request) { }; logger.info("📊 [CHAT-API] Final Token Usage:", usageInfo); + + if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) { + try { + await autumnBilling.track({ + headers: headersObj, + body: { featureId: "premium_message", value: 1 }, + }); + } catch (err) { + console.error("[billing] Autumn track failed:", err); + } + } }, onStepFinish: (result) => { // stepType exists in runtime but may not be in type definitions diff --git a/src/app/billing/layout.tsx b/src/app/billing/layout.tsx new file mode 100644 index 00000000..dc429b89 --- /dev/null +++ b/src/app/billing/layout.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { AnonymousSessionHandler } from "@/components/layout/SessionHandler"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/auth-client"; + +function BillingAccessGate({ children }: { children: React.ReactNode }) { + const { data: session, isPending } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (!isPending && (!session || session.user?.isAnonymous)) { + router.replace("/auth/sign-up?redirect_url=%2Fbilling"); + } + }, [isPending, router, session]); + + if (isPending || !session || session.user?.isAnonymous) { + return ( +
+
+ + +
+
+ + + +
+
+ + +
+
+ ); + } + + return <>{children}; +} + +export default function BillingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/billing/page.tsx b/src/app/billing/page.tsx new file mode 100644 index 00000000..d46b24b6 --- /dev/null +++ b/src/app/billing/page.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { useCustomer, useListPlans } from "autumn-js/react"; +import { useState } from "react"; +import { Loader2, CreditCard, Sparkles, Zap } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; + +type PremiumBalance = { + balance?: number; + limit?: number; + unlimited?: boolean; + remaining?: number; + granted?: number; +}; + +type CustomerSubscription = { + status?: string; + productId?: string; + planId?: string; + plan?: { + name?: string; + }; +}; + +type Customer = { + features?: { + premium_message?: PremiumBalance; + }; + balances?: { + premium_message?: PremiumBalance; + }; + subscriptions?: CustomerSubscription[]; +}; + +type PlanPrice = { + amount?: number; + interval?: string; + recurring?: { + interval?: string; + }; +}; + +type Plan = { + id: string; + name?: string; + description?: string; + price?: PlanPrice | number | null; + prices?: PlanPrice[]; + customerEligibility?: { + attachAction?: "activate" | "upgrade" | "downgrade" | "purchase" | "none"; + status?: "active" | "scheduled" | string; + trialAvailable?: boolean; + }; +}; + +type UseCustomerApi = ReturnType; + +function getAutumnCustomer(api: UseCustomerApi): Customer | null { + const customerApi = api as unknown as { + customer?: Customer | null; + data?: Customer | null; + }; + return customerApi.customer ?? customerApi.data ?? null; +} + +function getPremiumBalance(customer?: Customer | null) { + const premiumBalance = + customer?.features?.premium_message ?? customer?.balances?.premium_message; + + if (!premiumBalance) return null; + + return { + balance: premiumBalance.balance ?? premiumBalance.remaining ?? 0, + limit: premiumBalance.limit ?? premiumBalance.granted ?? 0, + unlimited: premiumBalance.unlimited ?? false, + }; +} + +async function attachPlan(api: UseCustomerApi, planId: string) { + const customerApi = api as unknown as { + attach?: (params: Record) => Promise; + }; + + if (!customerApi.attach) { + throw new Error("Attach is not available"); + } + + try { + return await customerApi.attach({ productId: planId }); + } catch (error) { + return await customerApi.attach({ planId }); + } +} + +async function cancelPlan(api: UseCustomerApi, planId: string) { + const customerApi = api as unknown as { + cancel?: (params: Record) => Promise; + updateSubscription?: (params: Record) => Promise; + }; + + if (customerApi.cancel) { + return customerApi.cancel({ productId: planId }); + } + + if (customerApi.updateSubscription) { + return customerApi.updateSubscription({ + planId, + cancelAction: "cancel_end_of_cycle", + }); + } + + throw new Error("Cancellation is not available"); +} + +async function openBillingPortal(api: UseCustomerApi, returnUrl: string) { + const customerApi = api as unknown as { + openBillingPortal?: (params: Record) => Promise; + openCustomerPortal?: (params: Record) => Promise; + }; + + if (customerApi.openBillingPortal) { + return customerApi.openBillingPortal({ returnUrl }); + } + + if (customerApi.openCustomerPortal) { + return customerApi.openCustomerPortal({ returnUrl }); + } + + throw new Error("Billing portal is not available"); +} + +function BillingSectionSkeleton() { + return ( +
+ + + + + +
+ ); +} + +function getPlanLabel(plan: Plan) { + const eligibility = plan.customerEligibility; + + if (eligibility?.attachAction === "none") { + return eligibility.status === "scheduled" ? "Scheduled" : "Current Plan"; + } + + switch (eligibility?.attachAction) { + case "upgrade": + return "Upgrade"; + case "downgrade": + return "Downgrade"; + case "purchase": + return "Purchase"; + case "activate": + return "Subscribe"; + default: + return plan.price ? "Choose Plan" : "Get Started"; + } +} + +function getPlanPrice(plan: Plan) { + const priceCandidate = + Array.isArray(plan.prices) && plan.prices.length > 0 + ? plan.prices[0] + : plan.price; + + if (priceCandidate == null) { + return { amount: null as number | null, interval: "month" }; + } + + if (typeof priceCandidate === "number") { + return { amount: priceCandidate, interval: "month" }; + } + + return { + amount: + typeof priceCandidate.amount === "number" ? priceCandidate.amount : null, + interval: + priceCandidate.interval || priceCandidate.recurring?.interval || "month", + }; +} + +function formatPlanPrice(plan: Plan) { + const { amount, interval } = getPlanPrice(plan); + + if (amount == null || amount <= 0) { + return "Free"; + } + + const normalizedAmount = amount / 100; + return `$${normalizedAmount.toFixed(normalizedAmount % 1 === 0 ? 0 : 2)}/${interval === "year" ? "yr" : "mo"}`; +} + +function UsageSection() { + const customerApi = useCustomer(); + const customer = getAutumnCustomer(customerApi); + const isLoading = customerApi.isLoading; + + if (isLoading) { + return ; + } + + const premiumBalance = getPremiumBalance(customer); + const remaining = premiumBalance?.balance ?? 0; + const limit = premiumBalance?.limit ?? 0; + const used = limit > 0 ? Math.max(0, limit - remaining) : 0; + const progress = limit > 0 ? (used / limit) * 100 : 0; + const activeSubscription = customer?.subscriptions?.find( + (subscription) => subscription.status === "active", + ); + const currentPlan = + activeSubscription?.plan?.name || + activeSubscription?.productId || + activeSubscription?.planId || + (premiumBalance?.unlimited ? "Pro" : "Free"); + + return ( +
+
+
+

Your Plan

+

+ Track your premium AI usage and current subscription status. +

+
+ + {currentPlan} + +
+ + {premiumBalance ? ( + premiumBalance.unlimited ? ( +
+
+ + Unlimited premium messages +
+

+ You have unlimited access to premium chat models on your current + plan. +

+
+ ) : ( +
+
+
+

Premium messages

+

+ {remaining} remaining out of {limit} this month +

+
+
+ {limit > 0 + ? `${Math.round((remaining / limit) * 100)}% left` + : "0% left"} +
+
+ +

+ Only premium models consume credits. Basic models remain + unlimited. +

+
+ ) + ) : ( +
+ Usage data will appear here once your billing profile is loaded. +
+ )} +
+ ); +} + +function PricingTable() { + const { + data: plans, + isLoading, + error, + } = useListPlans() as { + data?: Plan[]; + isLoading?: boolean; + error?: Error | null; + }; + const customerApi = useCustomer(); + const [submittingPlanId, setSubmittingPlanId] = useState(null); + + const handleAttach = async (planId: string) => { + setSubmittingPlanId(planId); + try { + await attachPlan(customerApi, planId); + } catch (error) { + console.error("Failed to attach plan", error); + toast.error("Unable to start checkout", { + description: "Please try again in a moment.", + }); + } finally { + setSubmittingPlanId(null); + } + }; + + if (isLoading) { + return ( +
+
+

Plans

+

+ Choose the plan that matches your usage. +

+
+
+ + +
+
+ ); + } + + if (error) { + return ( +
+

Plans

+

+ We couldn't load pricing right now. Please refresh and try again. +

+
+ ); + } + + return ( +
+
+

Plans

+

+ Upgrade for unlimited premium messages, or stay on the free tier. +

+
+ +
+ {plans?.map((plan) => { + const disabled = plan.customerEligibility?.attachAction === "none"; + const isCurrent = plan.customerEligibility?.status === "active"; + const isProcessing = submittingPlanId === plan.id; + + return ( +
+
+
+
+

+ {plan.name || plan.id} +

+

+ {plan.description || + (plan.id === "pro" + ? "Unlimited premium messages and unrestricted access to all models." + : "100 premium AI messages per month with unlimited basic models.")} +

+
+ {isCurrent ? Active : null} +
+
+ {formatPlanPrice(plan)} +
+ {plan.customerEligibility?.trialAvailable ? ( +

+ Trial available +

+ ) : null} +
+ +
+ {plan.id === "pro" ? ( + <> +
+ + Unlimited premium messages +
+
+ + Unlimited access to all chat models +
+ + ) : ( + <> +
+ + 100 premium AI messages each month +
+
+ + Unlimited basic models +
+ + )} +
+ +
+ +
+
+ ); + })} +
+
+ ); +} + +function SubscriptionManagement() { + const customerApi = useCustomer(); + const customer = getAutumnCustomer(customerApi); + const [isCancelling, setIsCancelling] = useState(false); + const [isPortalLoading, setIsPortalLoading] = useState(false); + + const premiumBalance = getPremiumBalance(customer); + const subscriptions = customer?.subscriptions || []; + const hasActivePaidPlan = + premiumBalance?.unlimited || + subscriptions.some( + (subscription) => + subscription.status === "active" && + (subscription.productId === "pro" || subscription.planId === "pro"), + ); + + const handleCancel = async () => { + setIsCancelling(true); + try { + await cancelPlan(customerApi, "pro"); + toast.success("Plan cancellation requested", { + description: + "Your Pro plan will remain active until the current billing period ends.", + }); + } catch (error) { + console.error("Failed to cancel plan", error); + toast.error("Unable to cancel plan", { + description: "Please try again from the billing portal.", + }); + } finally { + setIsCancelling(false); + } + }; + + const handleOpenPortal = async () => { + setIsPortalLoading(true); + try { + await openBillingPortal(customerApi, window.location.href); + } catch (error) { + console.error("Failed to open billing portal", error); + toast.error("Unable to open billing portal", { + description: "Please try again in a moment.", + }); + } finally { + setIsPortalLoading(false); + } + }; + + return ( +
+
+

Subscription Management

+

+ Manage your subscription, payment method, and billing history. +

+
+ + + + {hasActivePaidPlan ? ( +
+ + +
+ ) : ( +
+ You're currently on the free plan. Upgrade to Pro to manage + billing in Stripe. +
+ )} +
+ ); +} + +export default function BillingPage() { + return ( +
+
+

+ Plans & Billing +

+

+ Manage your subscription, track premium message usage, and update + billing details. +

+
+ + + + +
+ ); +} diff --git a/src/components/assistant-ui/ModelPicker.tsx b/src/components/assistant-ui/ModelPicker.tsx index c63be640..bb0e2517 100644 --- a/src/components/assistant-ui/ModelPicker.tsx +++ b/src/components/assistant-ui/ModelPicker.tsx @@ -125,37 +125,12 @@ function ModelPickerRow({ {model.ui.description}

-
-
-
- Speed -
-
- {model.ui.speed} -
+
+
+ Speed
-
-
- Cost -
-
- {Array.from({ length: 3 }, (_, index) => { - const isActive = index < model.ui.costLevel; - return ( - - $ - - ); - })} -
+
+ {model.ui.speed}
diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 8b76b7a8..ab57c1a4 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -13,6 +13,7 @@ import { import type { UIMessage } from "ai"; import { useMemo, useCallback, useRef } from "react"; import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext"; +import { UpgradeDialog } from "@/components/billing/UpgradeDialog"; import { useUIStore } from "@/lib/stores/ui-store"; import { toast } from "sonner"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; @@ -27,6 +28,12 @@ interface WorkspaceRuntimeProviderProps { children: React.ReactNode; } +function UpgradeDialogConnected() { + const open = useUIStore((state) => state.showUpgradeDialog); + const setOpen = useUIStore((state) => state.setShowUpgradeDialog); + return ; +} + function createWorkspaceChatRuntimeHook( transport: AssistantChatTransport, onError: (error: Error) => void, @@ -79,12 +86,7 @@ export function WorkspaceRuntimeProvider({ activePdfPageByItemId, viewingItemIds, ); - }, [ - workspaceState, - contextCardIds, - activePdfPageByItemId, - viewingItemIds, - ]); + }, [workspaceState, contextCardIds, activePdfPageByItemId, viewingItemIds]); // Per AI SDK, transport `body` is `Resolvable` — if it is a function, `resolve()` // calls it on every sendMessages (see @ai-sdk/provider-utils resolve()). That gives @@ -131,6 +133,11 @@ export function WorkspaceRuntimeProvider({ description: "Unable to reach the server. Please check your connection.", }); + } else if ( + combinedMessage.includes("credits_exhausted") || + combinedMessage.includes("premium ai messages") + ) { + useUIStore.getState().setShowUpgradeDialog(true); } else if ( combinedMessage.includes("500") || combinedMessage.includes("internal server") @@ -206,7 +213,10 @@ export function WorkspaceRuntimeProvider({ return ( - {children} + + {children} + + ); } diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index d78e2b97..d84f828e 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -21,6 +21,7 @@ import { Brain, Play, Search, + Zap, } from "lucide-react"; import { FaWandMagicSparkles } from "react-icons/fa6"; import { LuBook } from "react-icons/lu"; @@ -83,10 +84,7 @@ import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay"; import { MessageContextBadges } from "@/components/chat/MessageContextBadges"; import { MentionMenu } from "@/components/chat/MentionMenu"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { - useUIStore, - selectReplySelections, -} from "@/lib/stores/ui-store"; +import { useUIStore, selectReplySelections } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; import { useShallow } from "zustand/react/shallow"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; @@ -95,6 +93,7 @@ import { useFeatureFlagEnabled } from "posthog-js/react"; import { toast } from "sonner"; import { filterItems } from "@/lib/workspace-state/search"; import { useSession } from "@/lib/auth-client"; +import { isPremiumChatModel } from "@/lib/ai/models"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { buildWorkspaceItemDefinitionsFromAssets } from "@/lib/uploads/uploaded-asset"; import { uploadSelectedFiles } from "@/lib/uploads/upload-selection"; @@ -110,6 +109,7 @@ import { type PromptBuilderAction, } from "@/components/assistant-ui/PromptBuilderDialog"; import { ModelPicker } from "@/components/assistant-ui/ModelPicker"; +import { useCustomer } from "autumn-js/react"; import { ThinkExLogo } from "@/components/ui/thinkex-logo"; interface ThreadProps { @@ -121,39 +121,37 @@ export const Thread: FC = ({ items = [] }) => { return ( + - - thread.isLoading}> - - - thread.isEmpty && !thread.isLoading} - > - - + thread.isLoading}> + + + thread.isEmpty && !thread.isLoading}> + + - - + + -
- -
-
+
+ +
+ ); }; @@ -883,8 +881,7 @@ const Composer: FC = ({ items }) => { // Combine all context: selected cards, reply texts, and user message // Use placeholder when empty so AI SDK accepts (backend injects reply context into message) let modifiedText = - currentText.trim() || - (hasReplyContext ? "Empty message" : ""); + currentText.trim() || (hasReplyContext ? "Empty message" : ""); // Attach per-request context as metadata via runConfig // This flows through as body.metadata.custom on the server @@ -956,13 +953,38 @@ interface ComposerActionProps { const ComposerAction: FC = ({ items }) => { const { data: session } = useSession(); + const { customer } = useCustomer() as { + customer?: { + features?: { + premium_message?: { + balance?: number; + limit?: number; + unlimited?: boolean; + remaining?: number; + granted?: number; + }; + }; + balances?: { + premium_message?: { + balance?: number; + limit?: number; + unlimited?: boolean; + remaining?: number; + granted?: number; + }; + }; + } | null; + }; useAui(); const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); const isAnonymous = session?.user?.isAnonymous ?? false; const { selectedCardIds } = useSelectedCardIds(); const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); + const selectedModelId = useUIStore((state) => state.selectedModelId); + const setSelectedModelId = useUIStore((state) => state.setSelectedModelId); const [isWarningPopoverOpen, setIsWarningPopoverOpen] = useState(false); + const [isLowCreditsPopoverOpen, setIsLowCreditsPopoverOpen] = useState(false); const hoverTimeoutRef = useRef(null); const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); @@ -985,6 +1007,19 @@ const ComposerAction: FC = ({ items }) => { return filterItems(items, ""); }, [items]); + const premiumBalance = + customer?.features?.premium_message ?? customer?.balances?.premium_message; + const remainingPremiumMessages = + premiumBalance?.balance ?? premiumBalance?.remaining ?? 0; + const premiumLimit = premiumBalance?.limit ?? premiumBalance?.granted ?? 0; + const hasLowPremiumCredits = + !isAnonymous && + !!premiumBalance && + !premiumBalance.unlimited && + premiumLimit > 0 && + remainingPremiumMessages / premiumLimit < 0.2; + const isPremiumModelSelected = isPremiumChatModel(selectedModelId); + return (
{/* Attachment buttons on the left */} @@ -1091,6 +1126,89 @@ const ComposerAction: FC = ({ items }) => { )} + {hasLowPremiumCredits && ( + { + setIsLowCreditsPopoverOpen(open); + if (!open) { + focusComposerInput(); + } + }} + > + + + + { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + setIsLowCreditsPopoverOpen(true); + }} + onMouseLeave={() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsLowCreditsPopoverOpen(false); + }, 100); + }} + className="w-72 p-3" + > +
+

+ You have {remainingPremiumMessages} premium messages left this + month. + {isPremiumModelSelected + ? " Your current model uses premium credits." + : " Premium credits are only used by premium models."} +

+
+ + + + +
+
+
+
+ )}
{/* Right side: speech/send/cancel button */}
diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 7cbb4f63..78ee45a6 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,19 +1,7 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { authClient, useSession } from "@/lib/auth-client"; -import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { AlertDialog, AlertDialogAction, @@ -24,6 +12,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient, useSession } from "@/lib/auth-client"; +import { CreditCard, Loader2 } from "lucide-react"; +import { toast } from "sonner"; interface AccountModalProps { open: boolean; @@ -43,6 +44,7 @@ export function AccountModal({ open, onOpenChange }: AccountModalProps) {
+
@@ -71,13 +73,13 @@ function ProfileForm({ user }: { user: any }) { return (
-
+
{user.name?.charAt(0) || "U"}
- {user.name} + {user.name} {user.email}
@@ -102,6 +104,23 @@ function ProfileForm({ user }: { user: any }) { ); } +function BillingSection() { + return ( +
+

Billing

+

+ Manage your subscription, view usage, and update payment methods. +

+ + + +
+ ); +} + function DangerZone() { const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -127,14 +146,14 @@ function DangerZone() { return ( <>
-

Danger Zone

-

- Permanently delete your account and all of your content. This action cannot be undone. +

+ Danger Zone +

+

+ Permanently delete your account and all of your content. This action + cannot be undone.

-
@@ -144,8 +163,8 @@ function DangerZone() { Are you absolutely sure? - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. @@ -155,7 +174,7 @@ function DangerZone() { e.preventDefault(); handleDeleteAccount(); }} - className="bg-red-500 hover:bg-red-600 focus:ring-red-500 text-white" + className="bg-red-500 text-white hover:bg-red-600 focus:ring-red-500" disabled={isDeleting} > {isDeleting ? "Deleting..." : "Delete Account"} diff --git a/src/components/billing/UpgradeDialog.tsx b/src/components/billing/UpgradeDialog.tsx new file mode 100644 index 00000000..98ca8972 --- /dev/null +++ b/src/components/billing/UpgradeDialog.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { UpgradeModal as UpgradeDialog } from "@/components/billing/UpgradeModal"; diff --git a/src/components/billing/UpgradeModal.tsx b/src/components/billing/UpgradeModal.tsx new file mode 100644 index 00000000..753ff983 --- /dev/null +++ b/src/components/billing/UpgradeModal.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { useCustomer } from "autumn-js/react"; +import { Zap } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface UpgradeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function UpgradeModal({ open, onOpenChange }: UpgradeModalProps) { + const customerApi = useCustomer(); + const [isLoading, setIsLoading] = useState(false); + + const handleUpgrade = async () => { + setIsLoading(true); + try { + const autumnApi = customerApi as unknown as { + attach?: (params: Record) => Promise; + }; + + if (!autumnApi.attach) { + throw new Error("Attach is not available"); + } + + try { + await autumnApi.attach({ productId: "pro" }); + } catch { + await autumnApi.attach({ planId: "pro" }); + } + } catch { + toast.error("Unable to start checkout. Redirecting to billing page."); + window.location.href = "/billing"; + } finally { + setIsLoading(false); + } + }; + + return ( + + + +
+ + Premium messages used up +
+ + You've used all your premium AI messages for this month. You + can still use basic models like Gemini Flash, Claude Haiku, and + GPT-5 for free. + +
+
+ + +
+
+
+ ); +} diff --git a/src/components/home/UserProfileDropdown.tsx b/src/components/home/UserProfileDropdown.tsx index 6775879e..bae4ee8b 100644 --- a/src/components/home/UserProfileDropdown.tsx +++ b/src/components/home/UserProfileDropdown.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCustomer } from "autumn-js/react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -29,6 +30,50 @@ interface UserProfileDropdownProps { initialAuth?: InitialAuth; } +type PremiumBalance = { + balance?: number; + limit?: number; + unlimited?: boolean; + remaining?: number; + granted?: number; +}; + +function CreditsBadge() { + const { customer } = useCustomer() as { + customer?: { + features?: { + premium_message?: PremiumBalance; + }; + balances?: { + premium_message?: PremiumBalance; + }; + } | null; + }; + + const premiumBalance = + customer?.features?.premium_message ?? customer?.balances?.premium_message; + + if (!premiumBalance || premiumBalance.unlimited) return null; + + const remaining = premiumBalance.balance ?? premiumBalance.remaining ?? 0; + const limit = premiumBalance.limit ?? premiumBalance.granted ?? 0; + const pct = limit > 0 ? (remaining / limit) * 100 : 0; + const dotColor = + pct > 20 ? "bg-green-500" : pct > 0 ? "bg-amber-500" : "bg-red-500"; + + return ( + <> +
+
+
+ {remaining} premium messages left +
+
+ + + ); +} + export function UserProfileDropdown({ initialAuth }: UserProfileDropdownProps) { const { data: session } = useSession(); const router = useRouter(); @@ -84,7 +129,7 @@ export function UserProfileDropdown({ initialAuth }: UserProfileDropdownProps) { + setShowAccountModal(true)} className="cursor-pointer" diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 00000000..260f30c8 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +interface ProgressProps extends ComponentProps<"div"> { + value?: number; + indicatorClassName?: string; +} + +export function Progress({ + className, + value = 0, + indicatorClassName, + ...props +}: ProgressProps) { + const normalizedValue = Number.isFinite(value) + ? Math.min(100, Math.max(0, value)) + : 0; + + return ( +
+
+
+ ); +} diff --git a/src/lib/ai/models.ts b/src/lib/ai/models.ts index 880142b8..efd3677b 100644 --- a/src/lib/ai/models.ts +++ b/src/lib/ai/models.ts @@ -252,6 +252,18 @@ export function getModelDefinition(id: string): ModelDefinition | undefined { return MODEL_REGISTRY[raw]; } +/** + * Whether a model ID refers to a premium (paid) chat model. + * Only models with tier "pro" are premium (Gemini Pro, Claude Sonnet). + * GPT-5 (tier "standard"), Flash/Haiku (tier "fast"/"lite"), and + * internal models (no `ui` field) are all free. + */ +export function isPremiumChatModel(modelId: string): boolean { + const def = getModelDefinition(modelId); + if (!def || !def.ui) return false; + return def.tier === "pro"; +} + export function resolveGatewayModelId(input: string): string { if (PROVIDER_PREFIX_RE.test(input)) return input; diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 26da9edc..76446cfd 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -68,6 +68,7 @@ interface UIState { // Modal state showCreateWorkspaceModal: boolean; showSheetModal: boolean; + showUpgradeDialog: boolean; activeFolderId: string | null; // Active folder for filtering selectedModelId: string; // Selected AI model ID @@ -117,6 +118,7 @@ interface UIState { setShowCreateWorkspaceModal: (show: boolean) => void; setShowSheetModal: (show: boolean) => void; + setShowUpgradeDialog: (show: boolean) => void; setActiveFolderId: (folderId: string | null) => void; /** Atomic: close panels and clear folder. Use when panel is focused and user navigates back. */ @@ -168,6 +170,7 @@ const initialState = { showCreateWorkspaceModal: false, showSheetModal: false, + showUpgradeDialog: false, activeFolderId: null, selectedModelId: getDefaultChatModelId(), @@ -322,6 +325,7 @@ export const useUIStore = create()( setShowCreateWorkspaceModal: (show) => set({ showCreateWorkspaceModal: show }), setShowSheetModal: (show) => set({ showSheetModal: show }), + setShowUpgradeDialog: (show) => set({ showUpgradeDialog: show }), setSelectedModelId: (modelId) => set({ selectedModelId: modelId }), @@ -412,6 +416,7 @@ export const useUIStore = create()( itemPrompt: null, showCreateWorkspaceModal: false, showSheetModal: false, + showUpgradeDialog: false, }), }), {