mirror of
https://github.com/langchain-ai/fullstack-chat-client.git
synced 2026-06-30 22:07:58 -04:00
credits deduction implementation, real time updating w optimistic updates and db validation/refund
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
# Credit Deduction System
|
||||
|
||||
This document explains how the credit deduction system is implemented in the fullstack chat application.
|
||||
|
||||
## Overview
|
||||
|
||||
The credit system deducts 1 credit from the user's account for every LLM request made through the chat interface. This includes:
|
||||
|
||||
- Sending new messages
|
||||
- Regenerating AI responses
|
||||
- Editing messages (which triggers a new LLM request)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **`deductUserCredits` function** (`src/lib/stripe.ts`)
|
||||
|
||||
- Handles the actual credit deduction logic
|
||||
- Updates user's credit balance in Supabase
|
||||
- Returns success status and new balance
|
||||
|
||||
2. **`useCreditDeduction` hook** (`src/hooks/use-credit-deduction.ts`)
|
||||
|
||||
- Centralized hook for credit deduction logic
|
||||
- Handles authentication checks
|
||||
- Provides user-friendly error messages
|
||||
- Shows toast notifications
|
||||
- **NEW**: Integrates with global credits context for real-time UI updates
|
||||
|
||||
3. **`CreditsProvider` context** (`src/providers/Credits.tsx`)
|
||||
|
||||
- **NEW**: Global state management for credits
|
||||
- Provides optimistic updates for instant UI feedback
|
||||
- Handles credit fetching and error states
|
||||
- Enables real-time credit balance updates across the app
|
||||
|
||||
4. **`CreditBalance` component** (`src/components/credits/credit-balance.tsx`)
|
||||
|
||||
- **NEW**: Real-time credit balance display with Badge design
|
||||
- Color-coded indicators (red/orange/green) based on credit levels
|
||||
- Automatic updates when credits are deducted
|
||||
- Loading and error states
|
||||
|
||||
5. **Credit integration points:**
|
||||
- `src/components/thread/index.tsx` - Main message submission and regeneration
|
||||
- `src/components/thread/messages/human.tsx` - Message editing
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Pre-request validation**: Before any LLM request is made, the system:
|
||||
|
||||
- Checks if the user is authenticated
|
||||
- **NEW**: Optimistically deducts credits from UI immediately
|
||||
- Attempts to deduct credits from database
|
||||
- Only proceeds with the LLM request if credits are successfully deducted
|
||||
- **NEW**: Reverts optimistic update if deduction fails
|
||||
|
||||
2. **Real-time UI Updates**:
|
||||
|
||||
- Credits are deducted from the UI instantly for immediate feedback
|
||||
- Database is updated in the background
|
||||
- UI is refreshed with actual balance from server
|
||||
- Failed requests automatically refund credits
|
||||
|
||||
3. **Error handling**: If credit deduction fails:
|
||||
|
||||
- **NEW**: Automatically reverts optimistic UI updates
|
||||
- Shows appropriate error messages (insufficient credits, authentication required)
|
||||
- Prevents the LLM request from being made
|
||||
- Suggests purchasing more credits when needed
|
||||
|
||||
4. **Success feedback**: When credits are deducted:
|
||||
- **NEW**: UI updates immediately (optimistic)
|
||||
- Shows a success toast with remaining balance
|
||||
- Proceeds with the LLM request
|
||||
- **NEW**: Refreshes with actual balance from server
|
||||
|
||||
### User Experience
|
||||
|
||||
- **Immediate feedback**: Users see credit deduction instantly in the UI
|
||||
- **Fail-fast approach**: Prevents unnecessary LLM calls when credits are insufficient
|
||||
- **Clear messaging**: Descriptive error messages explain what went wrong
|
||||
- **Real-time balance visibility**: Credit balance updates across all components instantly
|
||||
- **Automatic error recovery**: Failed requests automatically refund credits
|
||||
|
||||
## Credit Balance Display
|
||||
|
||||
The `CreditBalance` component (`src/components/credits/credit-balance.tsx`) shows users their current credit balance with:
|
||||
|
||||
- **Badge design** with professional styling
|
||||
- **Real-time updates** when credits are deducted/added
|
||||
- **Color-coded indicators**:
|
||||
- **Red**: 0 credits (urgent)
|
||||
- **Orange**: 1-5 credits (low)
|
||||
- **Green**: 6+ credits (good)
|
||||
- **Loading states** with pulse animation
|
||||
- **Error handling** with fallback displays
|
||||
- **Number formatting** (1,000 instead of 1000)
|
||||
|
||||
## Global State Management
|
||||
|
||||
### CreditsProvider Context
|
||||
|
||||
The `CreditsProvider` wraps the entire application and provides:
|
||||
|
||||
```typescript
|
||||
interface CreditsContextProps {
|
||||
credits: number | null; // Current credit balance
|
||||
loading: boolean; // Loading state
|
||||
error: string | null; // Error state
|
||||
refreshCredits: () => Promise<void>; // Refresh from server
|
||||
updateCredits: (newCredits: number) => void; // Set exact amount
|
||||
deductCredits: (amount: number) => void; // Optimistic deduction
|
||||
addCredits: (amount: number) => void; // Optimistic addition
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Pattern
|
||||
|
||||
Components can access and update credits using the context:
|
||||
|
||||
```typescript
|
||||
const { credits, deductCredits, addCredits } = useCreditsContext();
|
||||
|
||||
// Optimistic deduction
|
||||
deductCredits(1);
|
||||
// Later: refresh with actual balance
|
||||
refreshCredits();
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The credit system relies on the `users` table having these columns:
|
||||
|
||||
- `credits_available` (integer): Current credit balance
|
||||
- `subscription_status` (text): User's subscription status
|
||||
|
||||
## Error Scenarios
|
||||
|
||||
1. **Insufficient Credits**: User has 0 credits remaining
|
||||
2. **Authentication Required**: User is not signed in
|
||||
3. **Database Errors**: Network issues or database connectivity problems
|
||||
4. **Server Overload**: LangGraph server temporarily unavailable (credits auto-refunded)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic credit deduction:
|
||||
|
||||
```typescript
|
||||
const { deductCredits } = useCreditDeduction();
|
||||
const result = await deductCredits({ reason: "send message" });
|
||||
if (!result.success) {
|
||||
return; // Error already handled by hook
|
||||
}
|
||||
// Proceed with LLM request
|
||||
```
|
||||
|
||||
### Custom credit amount:
|
||||
|
||||
```typescript
|
||||
const result = await deductCredits({
|
||||
reason: "premium feature",
|
||||
creditsToDeduct: 5,
|
||||
showSuccessToast: false,
|
||||
});
|
||||
```
|
||||
|
||||
### Using global credit state:
|
||||
|
||||
```typescript
|
||||
const { credits, loading, error } = useCreditsContext();
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <ErrorMessage />;
|
||||
return <div>Credits: {credits}</div>;
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Message submission**: `handleSubmit` in Thread component
|
||||
- **Message regeneration**: `handleRegenerate` in Thread component
|
||||
- **Message editing**: `handleSubmitEdit` in HumanMessage component
|
||||
- **Global credit display**: `CreditBalance` component in navbar
|
||||
- **State management**: `CreditsProvider` in root layout
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Variable credit costs**: Different LLM models could cost different amounts
|
||||
2. **Credit packages**: Bulk credit purchases with discounts
|
||||
3. **Usage analytics**: Track credit usage patterns
|
||||
4. **Credit expiration**: Time-based credit expiration
|
||||
5. **Subscription integration**: Automatic credit replenishment for subscribers
|
||||
6. **Credit notifications**: Push notifications for low credit warnings
|
||||
7. **Credit history**: Transaction log for credit usage tracking
|
||||
@@ -15,10 +15,13 @@ First, clone the repository, or run the [`npx` command](https://www.npmjs.com/pa
|
||||
```bash
|
||||
npx create-agent-chat-app
|
||||
```
|
||||
|
||||
Stripe Local webhook testing (stripe-cli)
|
||||
|
||||
```
|
||||
stripe listen --events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted --forward-to localhost:3000/api/webhooks/stripe
|
||||
```
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
|
||||
import React from "react";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { AuthProvider } from "@/providers/Auth";
|
||||
import { CreditsProvider } from "@/providers/Credits";
|
||||
import AuthLayout from "./auth-layout";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -27,7 +28,9 @@ export default function RootLayout({
|
||||
<body className={inter.className}>
|
||||
<NuqsAdapter>
|
||||
<AuthProvider>
|
||||
<AuthLayout>{children}</AuthLayout>
|
||||
<CreditsProvider>
|
||||
<AuthLayout>{children}</AuthLayout>
|
||||
</CreditsProvider>
|
||||
</AuthProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Coins, AlertCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useUser } from "@/lib/auth/supabase-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CreditsDisplay() {
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredits = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("CreditsDisplay: Fetching credits for user:", user.id);
|
||||
const response = await fetch(`/api/user/credits?userId=${user.id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"CreditsDisplay: Credits API failed with status:",
|
||||
response.status,
|
||||
);
|
||||
const errorText = await response.text();
|
||||
console.error("CreditsDisplay: Error response:", errorText);
|
||||
setCredits(0); // Fallback to 0 credits
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("CreditsDisplay: Credits API response:", data);
|
||||
|
||||
if (data.credits !== undefined) {
|
||||
setCredits(data.credits);
|
||||
} else {
|
||||
setCredits(0); // Fallback if no credits field
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("CreditsDisplay: Failed to fetch credits:", error);
|
||||
setCredits(0); // Fallback to 0 credits on any error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCredits();
|
||||
}, [user]);
|
||||
|
||||
// Don't show anything if user is not logged in
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-muted flex animate-pulse items-center space-x-2 rounded-full px-3 py-1.5">
|
||||
<Coins className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine color based on credit amount
|
||||
const getVariant = (credits: number) => {
|
||||
if (credits === 0) return "secondary";
|
||||
if (credits < 1000) return "secondary";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const getIcon = (credits: number) => {
|
||||
if (credits === 0) return <AlertCircle className="h-4 w-4" />;
|
||||
return <Coins className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={getVariant(credits || 0)}
|
||||
className={cn(
|
||||
"flex items-center space-x-1 px-3 py-1.5 text-sm font-medium",
|
||||
credits === 0 && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
{getIcon(credits || 0)}
|
||||
<span>
|
||||
{credits === null
|
||||
? "Error"
|
||||
: credits >= 1000000
|
||||
? "Unlimited"
|
||||
: credits.toLocaleString()}{" "}
|
||||
credits
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useAuthContext } from "@/providers/Auth";
|
||||
import { useCreditsContext } from "@/providers/Credits";
|
||||
import { Coins, AlertCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CreditBalanceProps {
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function CreditBalance({
|
||||
className,
|
||||
showIcon = true,
|
||||
}: CreditBalanceProps) {
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { credits, loading, error } = useCreditsContext();
|
||||
|
||||
// Don't show anything if user is not logged in
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"flex animate-pulse items-center space-x-2 px-3 py-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showIcon && <Coins className="h-4 w-4" />}
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error || credits === null) {
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className={cn("flex items-center space-x-2 px-3 py-1.5", className)}
|
||||
>
|
||||
{showIcon && <AlertCircle className="h-4 w-4" />}
|
||||
<span className="text-sm font-medium">Error</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine badge variant based on credit amount
|
||||
const getBadgeVariant = (credits: number) => {
|
||||
if (credits === 0) return "destructive";
|
||||
if (credits <= 5) return "secondary";
|
||||
return "default";
|
||||
};
|
||||
|
||||
// Get appropriate icon based on credit amount
|
||||
const getIcon = (credits: number) => {
|
||||
if (credits === 0) return <AlertCircle className="h-4 w-4" />;
|
||||
return <Coins className="h-4 w-4 text-yellow-500" />;
|
||||
};
|
||||
|
||||
// Format credit display
|
||||
const formatCredits = (credits: number) => {
|
||||
if (credits >= 1000000) return "Unlimited";
|
||||
return credits.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={getBadgeVariant(credits)}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 px-3 py-1.5 text-sm font-medium",
|
||||
credits === 0 && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showIcon && getIcon(credits)}
|
||||
<span>
|
||||
{formatCredits(credits)} {credits === 1 ? "credit" : "credits"}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { UserInfoSignOut } from "@/features/user-auth-status";
|
||||
import { GraphDropdown } from "@/features/graph-dropdown";
|
||||
import { CreditsDisplay } from "@/components/credits-display";
|
||||
import { CreditBalance } from "@/components/credits/credit-balance";
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
@@ -52,7 +52,7 @@ export function Navbar() {
|
||||
</div>
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<div className="hidden items-center space-x-3 md:flex">
|
||||
<CreditsDisplay />
|
||||
<CreditBalance />
|
||||
<UserInfoSignOut />
|
||||
<GraphDropdown />
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@ export function Navbar() {
|
||||
<SheetContent side="right">
|
||||
<nav className="flex flex-col gap-4">
|
||||
<div className="mb-4">
|
||||
<CreditsDisplay />
|
||||
<CreditBalance />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
+106
-23
@@ -46,6 +46,7 @@ import {
|
||||
ArtifactTitle,
|
||||
useArtifactContext,
|
||||
} from "./artifact";
|
||||
import { useCreditDeduction } from "@/hooks/use-credit-deduction";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
@@ -145,6 +146,8 @@ export function Thread() {
|
||||
|
||||
const lastError = useRef<string | undefined>(undefined);
|
||||
|
||||
const { deductCredits } = useCreditDeduction();
|
||||
|
||||
const setThreadId = (id: string | null) => {
|
||||
_setThreadId(id);
|
||||
|
||||
@@ -195,10 +198,18 @@ export function Thread() {
|
||||
prevMessageLength.current = messages.length;
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
|
||||
return;
|
||||
|
||||
// Deduct 1 credit before making the LLM request
|
||||
const creditResult = await deductCredits({ reason: "send message" });
|
||||
|
||||
if (!creditResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFirstTokenReceived(false);
|
||||
|
||||
const newHumanMessage: Message = {
|
||||
@@ -215,36 +226,108 @@ export function Thread() {
|
||||
const context =
|
||||
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
|
||||
|
||||
stream.submit(
|
||||
{ messages: [...toolMessages, newHumanMessage], context },
|
||||
{
|
||||
streamMode: ["values"],
|
||||
optimisticValues: (prev) => ({
|
||||
...prev,
|
||||
context,
|
||||
messages: [
|
||||
...(prev.messages ?? []),
|
||||
...toolMessages,
|
||||
newHumanMessage,
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
try {
|
||||
stream.submit(
|
||||
{ messages: [...toolMessages, newHumanMessage], context },
|
||||
{
|
||||
streamMode: ["values"],
|
||||
optimisticValues: (prev) => ({
|
||||
...prev,
|
||||
context,
|
||||
messages: [
|
||||
...(prev.messages ?? []),
|
||||
...toolMessages,
|
||||
newHumanMessage,
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
setInput("");
|
||||
setContentBlocks([]);
|
||||
setInput("");
|
||||
setContentBlocks([]);
|
||||
} catch (error: any) {
|
||||
// Handle server overload and other errors
|
||||
if (
|
||||
error?.error?.type === "overloaded_error" ||
|
||||
error?.message?.includes("Overloaded")
|
||||
) {
|
||||
// Refund credits for overloaded server
|
||||
if (creditResult.refundCredits) {
|
||||
await creditResult.refundCredits();
|
||||
}
|
||||
|
||||
toast.error("Server temporarily overloaded", {
|
||||
description:
|
||||
"The AI server is busy. Please try again in a moment. Your credit has been refunded.",
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
// For other errors, still refund credits since the request failed
|
||||
if (creditResult.refundCredits) {
|
||||
await creditResult.refundCredits();
|
||||
}
|
||||
|
||||
toast.error("Request failed", {
|
||||
description:
|
||||
"There was an error processing your message. Your credit has been refunded.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Submit error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = (
|
||||
const handleRegenerate = async (
|
||||
parentCheckpoint: Checkpoint | null | undefined,
|
||||
) => {
|
||||
// Deduct 1 credit before making the LLM request
|
||||
const creditResult = await deductCredits({ reason: "regenerate message" });
|
||||
|
||||
if (!creditResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do this so the loading state is correct
|
||||
prevMessageLength.current = prevMessageLength.current - 1;
|
||||
setFirstTokenReceived(false);
|
||||
stream.submit(undefined, {
|
||||
checkpoint: parentCheckpoint,
|
||||
streamMode: ["values"],
|
||||
});
|
||||
|
||||
try {
|
||||
stream.submit(undefined, {
|
||||
checkpoint: parentCheckpoint,
|
||||
streamMode: ["values"],
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle server overload and other errors
|
||||
if (
|
||||
error?.error?.type === "overloaded_error" ||
|
||||
error?.message?.includes("Overloaded")
|
||||
) {
|
||||
// Refund credits for overloaded server
|
||||
if (creditResult.refundCredits) {
|
||||
await creditResult.refundCredits();
|
||||
}
|
||||
|
||||
toast.error("Server temporarily overloaded", {
|
||||
description:
|
||||
"The AI server is busy. Please try again in a moment. Your credit has been refunded.",
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
// For other errors, still refund credits since the request failed
|
||||
if (creditResult.refundCredits) {
|
||||
await creditResult.refundCredits();
|
||||
}
|
||||
|
||||
toast.error("Regeneration failed", {
|
||||
description:
|
||||
"There was an error regenerating the message. Your credit has been refunded.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Regenerate error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const chatStarted = !!threadId || !!messages.length;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
|
||||
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
|
||||
import { useCreditDeduction } from "@/hooks/use-credit-deduction";
|
||||
|
||||
function EditableContent({
|
||||
value,
|
||||
@@ -42,6 +43,7 @@ export function HumanMessage({
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const thread = useStreamContext();
|
||||
const { deductCredits } = useCreditDeduction();
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
|
||||
@@ -49,7 +51,14 @@ export function HumanMessage({
|
||||
const [value, setValue] = useState("");
|
||||
const contentString = getContentString(message.content);
|
||||
|
||||
const handleSubmitEdit = () => {
|
||||
const handleSubmitEdit = async () => {
|
||||
// Deduct 1 credit before making the LLM request for the edited message
|
||||
const creditResult = await deductCredits({ reason: "edit message" });
|
||||
|
||||
if (!creditResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
|
||||
const newMessage: Message = { type: "human", content: value };
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useAuthContext } from "@/providers/Auth";
|
||||
import { useCreditsContext } from "@/providers/Credits";
|
||||
import { deductUserCredits, addUserCredits } from "@/lib/stripe";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CreditDeductionOptions {
|
||||
/** The reason for the credit deduction (used in toast messages) */
|
||||
reason?: string;
|
||||
/** Number of credits to deduct (default: 1) */
|
||||
creditsToDeduct?: number;
|
||||
/** Whether to show success toast (default: true) */
|
||||
showSuccessToast?: boolean;
|
||||
}
|
||||
|
||||
interface CreditDeductionResult {
|
||||
success: boolean;
|
||||
newBalance?: number;
|
||||
error?: string;
|
||||
refundCredits?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCreditDeduction() {
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const {
|
||||
deductCredits: optimisticDeduct,
|
||||
addCredits: optimisticAdd,
|
||||
refreshCredits,
|
||||
} = useCreditsContext();
|
||||
|
||||
const deductCredits = async (
|
||||
options: CreditDeductionOptions = {},
|
||||
): Promise<CreditDeductionResult> => {
|
||||
const {
|
||||
reason = "message",
|
||||
creditsToDeduct = 1,
|
||||
showSuccessToast = true,
|
||||
} = options;
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isAuthenticated || !user?.id) {
|
||||
toast.error("Authentication required", {
|
||||
description: `You must be signed in to ${reason === "message" ? "send messages" : reason}.`,
|
||||
duration: 5000,
|
||||
});
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Optimistically deduct credits from UI immediately
|
||||
optimisticDeduct(creditsToDeduct);
|
||||
|
||||
// Deduct credits from database
|
||||
const creditResult = await deductUserCredits(user.id, creditsToDeduct);
|
||||
|
||||
if (!creditResult.success) {
|
||||
// Revert the optimistic update by adding credits back
|
||||
optimisticAdd(creditsToDeduct);
|
||||
|
||||
toast.error("Insufficient credits", {
|
||||
description: `You don't have enough credits to ${reason === "message" ? "send this message" : reason}.`,
|
||||
duration: 5000,
|
||||
});
|
||||
return { success: false, error: "Insufficient credits" };
|
||||
}
|
||||
|
||||
// Update the credits context with the actual balance from server
|
||||
refreshCredits();
|
||||
|
||||
// Show success toast if enabled
|
||||
if (showSuccessToast) {
|
||||
const creditText = creditsToDeduct === 1 ? "credit" : "credits";
|
||||
toast.success(`${creditsToDeduct} ${creditText} deducted`, {
|
||||
description: `Used for ${reason}. Remaining balance: ${creditResult.newBalance}`,
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Return result with refund function for failed requests
|
||||
return {
|
||||
success: true,
|
||||
newBalance: creditResult.newBalance,
|
||||
refundCredits: async () => {
|
||||
try {
|
||||
await addUserCredits(user.id, creditsToDeduct);
|
||||
// Update UI optimistically
|
||||
optimisticAdd(creditsToDeduct);
|
||||
// Refresh to get exact balance
|
||||
refreshCredits();
|
||||
|
||||
toast.info("Credits refunded", {
|
||||
description: `${creditsToDeduct} ${creditsToDeduct === 1 ? "credit" : "credits"} refunded due to server error.`,
|
||||
duration: 4000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to refund credits:", error);
|
||||
// Refresh credits to get the correct state if refund fails
|
||||
refreshCredits();
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Revert the optimistic update
|
||||
optimisticAdd(creditsToDeduct);
|
||||
|
||||
if (error.message === "Insufficient credits") {
|
||||
toast.error("Insufficient credits", {
|
||||
description: `You don't have enough credits to ${reason === "message" ? "send this message" : reason}. Please purchase more credits.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
toast.error("Credit deduction failed", {
|
||||
description:
|
||||
"There was an error processing your credits. Please try again.",
|
||||
duration: 5000,
|
||||
});
|
||||
console.error("Credit deduction error:", error);
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
deductCredits,
|
||||
isAuthenticated,
|
||||
userId: user?.id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useAuthContext } from "@/providers/Auth";
|
||||
import { supabase } from "@/lib/auth/supabase-client";
|
||||
|
||||
interface CreditsContextProps {
|
||||
credits: number | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshCredits: () => Promise<void>;
|
||||
updateCredits: (newCredits: number) => void;
|
||||
deductCredits: (amount: number) => void;
|
||||
addCredits: (amount: number) => void;
|
||||
}
|
||||
|
||||
const CreditsContext = createContext<CreditsContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function CreditsProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshCredits = async () => {
|
||||
if (!isAuthenticated || !user?.id) {
|
||||
setCredits(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: supabaseError } = await supabase
|
||||
.from("users")
|
||||
.select("credits_available")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (supabaseError) {
|
||||
console.error("Error fetching credits:", supabaseError);
|
||||
setError("Failed to fetch credits");
|
||||
setCredits(0); // Fallback to 0 credits
|
||||
return;
|
||||
}
|
||||
|
||||
setCredits((data?.credits_available as number) ?? 0);
|
||||
} catch (err) {
|
||||
console.error("Error fetching credits:", err);
|
||||
setError("Failed to fetch credits");
|
||||
setCredits(0); // Fallback to 0 credits on any error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update credits optimistically
|
||||
const updateCredits = (newCredits: number) => {
|
||||
setCredits(newCredits);
|
||||
};
|
||||
|
||||
// Deduct credits optimistically
|
||||
const deductCredits = (amount: number) => {
|
||||
setCredits((prevCredits) => {
|
||||
if (prevCredits === null) return null;
|
||||
return Math.max(0, prevCredits - amount);
|
||||
});
|
||||
};
|
||||
|
||||
// Add credits optimistically
|
||||
const addCredits = (amount: number) => {
|
||||
setCredits((prevCredits) => {
|
||||
if (prevCredits === null) return null;
|
||||
return prevCredits + amount;
|
||||
});
|
||||
};
|
||||
|
||||
// Initial fetch when user changes
|
||||
useEffect(() => {
|
||||
refreshCredits();
|
||||
}, [isAuthenticated, user?.id]);
|
||||
|
||||
const value = {
|
||||
credits,
|
||||
loading,
|
||||
error,
|
||||
refreshCredits,
|
||||
updateCredits,
|
||||
deductCredits,
|
||||
addCredits,
|
||||
};
|
||||
|
||||
return (
|
||||
<CreditsContext.Provider value={value}>{children}</CreditsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCreditsContext() {
|
||||
const context = useContext(CreditsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useCreditsContext must be used within a CreditsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -50,8 +50,9 @@ const StreamSession = ({
|
||||
}) => {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const { getThreads, setThreads } = useThreads();
|
||||
const { session } = useAuthContext();
|
||||
const { session, isLoading: authLoading } = useAuthContext();
|
||||
const jwt = session?.accessToken || undefined;
|
||||
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl,
|
||||
assistantId,
|
||||
@@ -78,6 +79,18 @@ const StreamSession = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until auth is fully loaded
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user