feat: add credit purchase functionality with Stripe integration
- Introduced credit purchase payment intent actions in credits.action.ts. - Created new components for credit packages and Stripe payment form. - Added routes and layout for credits settings page. - Updated sidebar configuration to include credits settings. - Enhanced constants for credit packages with detailed pricing and descriptions. - Implemented loading and layout components for credits page. - Integrated payment confirmation handling in Stripe provider.
This commit is contained in:
parent
98421afab8
commit
fe2b1bbe39
@ -63,6 +63,7 @@ STORAGE_PUBLIC_URL=""
|
|||||||
# https://mksaas.com/docs/payment#setup
|
# https://mksaas.com/docs/payment#setup
|
||||||
# Get Stripe key and secret from https://dashboard.stripe.com
|
# Get Stripe key and secret from https://dashboard.stripe.com
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
|
||||||
STRIPE_SECRET_KEY=""
|
STRIPE_SECRET_KEY=""
|
||||||
STRIPE_WEBHOOK_SECRET=""
|
STRIPE_WEBHOOK_SECRET=""
|
||||||
# Pro plan - monthly subscription
|
# Pro plan - monthly subscription
|
||||||
|
@ -556,6 +556,13 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"errorMessage": "Failed to get data"
|
"errorMessage": "Failed to get data"
|
||||||
},
|
},
|
||||||
|
"credits": {
|
||||||
|
"title": "Credits",
|
||||||
|
"description": "Manage your credits",
|
||||||
|
"credits": "Credits",
|
||||||
|
"creditsDescription": "You have {credits} credits",
|
||||||
|
"creditsExpired": "Credits expired"
|
||||||
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"title": "Notification",
|
"title": "Notification",
|
||||||
"description": "Manage your notification preferences",
|
"description": "Manage your notification preferences",
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addMonthlyFreeCredits,
|
addMonthlyFreeCredits,
|
||||||
addRegisterGiftCredits,
|
addRegisterGiftCredits,
|
||||||
consumeCredits,
|
consumeCredits,
|
||||||
getUserCredits,
|
getUserCredits,
|
||||||
|
addCredits,
|
||||||
} from '@/lib/credits';
|
} from '@/lib/credits';
|
||||||
import { getSession } from '@/lib/server';
|
import { getSession } from '@/lib/server';
|
||||||
import { createSafeActionClient } from 'next-safe-action';
|
import { createSafeActionClient } from 'next-safe-action';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { createPaymentIntent, confirmPaymentIntent } from '@/payment';
|
||||||
|
import { CREDIT_PACKAGES } from '@/lib/constants';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
const actionClient = createSafeActionClient();
|
const actionClient = createSafeActionClient();
|
||||||
|
|
||||||
@ -57,3 +63,84 @@ export const consumeCreditsAction = actionClient
|
|||||||
return { success: false, error: (e as Error).message };
|
return { success: false, error: (e as Error).message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Credit purchase payment intent action
|
||||||
|
const createPaymentIntentSchema = z.object({
|
||||||
|
packageId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCreditPaymentIntent = actionClient
|
||||||
|
.schema(createPaymentIntentSchema)
|
||||||
|
.action(async ({ parsedInput }) => {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return { success: false, error: 'User not authenticated' };
|
||||||
|
|
||||||
|
const { packageId } = parsedInput;
|
||||||
|
|
||||||
|
// Find the credit package
|
||||||
|
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||||
|
if (!creditPackage) {
|
||||||
|
return { success: false, error: 'Invalid credit package' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create payment intent
|
||||||
|
const paymentIntent = await createPaymentIntent({
|
||||||
|
amount: creditPackage.price * 100, // Convert to cents
|
||||||
|
currency: 'usd',
|
||||||
|
metadata: {
|
||||||
|
packageId,
|
||||||
|
userId: session.user.id,
|
||||||
|
credits: creditPackage.credits.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clientSecret: paymentIntent.clientSecret,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create credit payment intent error:', error);
|
||||||
|
return { success: false, error: 'Failed to create payment intent' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm credit payment action
|
||||||
|
const confirmPaymentSchema = z.object({
|
||||||
|
packageId: z.string().min(1),
|
||||||
|
paymentIntentId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const confirmCreditPayment = actionClient
|
||||||
|
.schema(confirmPaymentSchema)
|
||||||
|
.action(async ({ parsedInput }) => {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return { success: false, error: 'User not authenticated' };
|
||||||
|
|
||||||
|
const { packageId, paymentIntentId } = parsedInput;
|
||||||
|
|
||||||
|
// Find the credit package
|
||||||
|
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||||
|
if (!creditPackage) {
|
||||||
|
return { success: false, error: 'Invalid credit package' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Confirm payment intent
|
||||||
|
const isSuccessful = await confirmPaymentIntent({
|
||||||
|
paymentIntentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSuccessful) {
|
||||||
|
return { success: false, error: 'Payment confirmation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate the credits page to show updated balance
|
||||||
|
revalidatePath('/settings/credits');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Confirm credit payment error:', error);
|
||||||
|
return { success: false, error: 'Failed to confirm payment' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal file
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
interface CreditsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreditsLayout({ children }: CreditsLayoutProps) {
|
||||||
|
const t = await getTranslations('Dashboard.settings');
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
label: t('title'),
|
||||||
|
isCurrentPage: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('credits.title'),
|
||||||
|
isCurrentPage: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||||
|
<div className="px-4 lg:px-6 space-y-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
{t('credits.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{t('credits.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||||
|
}
|
9
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal file
9
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { CreditPackages } from '@/components/settings/credits/credit-packages';
|
||||||
|
|
||||||
|
export default function CreditsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<CreditPackages />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
216
src/components/settings/credits/credit-packages.tsx
Normal file
216
src/components/settings/credits/credit-packages.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { CREDIT_PACKAGES } from '@/lib/constants';
|
||||||
|
import { formatPrice } from '@/lib/formatter';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CheckIcon, CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Separator } from '../../ui/separator';
|
||||||
|
import { StripePaymentForm } from './stripe-payment-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function CreditPackages() {
|
||||||
|
const [loadingPackage, setLoadingPackage] = useState<string | null>(null);
|
||||||
|
const [paymentDialog, setPaymentDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
clientSecret: string | null;
|
||||||
|
packageId: string | null;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
clientSecret: null,
|
||||||
|
packageId: null,
|
||||||
|
});
|
||||||
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchCredits = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getCreditsAction();
|
||||||
|
if (result?.data?.success) {
|
||||||
|
console.log('CreditPackages, fetched credits:', result.data.credits);
|
||||||
|
setCredits(result.data.credits || 0);
|
||||||
|
} else {
|
||||||
|
const errorMessage = result?.data?.error || 'Failed to fetch credits';
|
||||||
|
console.error('CreditPackages, failed to fetch credits:', errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CreditPackages, failed to fetch credits:', error);
|
||||||
|
toast.error('Failed to fetch credits');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchase = async (packageId: string) => {
|
||||||
|
try {
|
||||||
|
setLoadingPackage(packageId);
|
||||||
|
|
||||||
|
const result = await createCreditPaymentIntent({ packageId });
|
||||||
|
if (result?.data?.success && result?.data?.clientSecret) {
|
||||||
|
setPaymentDialog({
|
||||||
|
isOpen: true,
|
||||||
|
clientSecret: result.data.clientSecret,
|
||||||
|
packageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorMessage = result?.data?.error || 'Failed to create payment intent';
|
||||||
|
console.error('CreditPackages, failed to create payment intent:', errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CreditPackages, failed to initiate payment:', error);
|
||||||
|
toast.error('Failed to initiate payment');
|
||||||
|
} finally {
|
||||||
|
setLoadingPackage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentSuccess = () => {
|
||||||
|
console.log('CreditPackages, payment successful');
|
||||||
|
setPaymentDialog({
|
||||||
|
isOpen: false,
|
||||||
|
clientSecret: null,
|
||||||
|
packageId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh credit balance without page reload
|
||||||
|
fetchCredits();
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast.success('Your credits have been added to your account');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentCancel = () => {
|
||||||
|
console.log('CreditPackages, payment cancelled');
|
||||||
|
setPaymentDialog({
|
||||||
|
isOpen: false,
|
||||||
|
clientSecret: null,
|
||||||
|
packageId: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPackageInfo = (packageId: string) => {
|
||||||
|
return CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-lg font-medium">Credit Balance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{loading ? (
|
||||||
|
<span className="animate-pulse">...</span>
|
||||||
|
) : (
|
||||||
|
credits?.toLocaleString() || 0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-medium">Credit Packages</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Purchase additional credits to use our services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{CREDIT_PACKAGES.map((pkg) => (
|
||||||
|
<Card key={pkg.id} className={cn(`relative ${pkg.popular ? 'border-primary' : ''}`,
|
||||||
|
'shadow-none border-1 border-border')}>
|
||||||
|
{pkg.popular && (
|
||||||
|
<div className="absolute -top-3.5 left-1/2 transform -translate-x-1/2">
|
||||||
|
<Badge variant="default" className="bg-primary text-primary-foreground">
|
||||||
|
Most Popular
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-lg capitalize">{pkg.id}</CardTitle>
|
||||||
|
</CardHeader> */}
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Price and Credits - Left/Right Layout */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-2xl font-semibold flex items-center gap-2">
|
||||||
|
<CoinsIcon className="h-4 w-4 text-muted-foreground" /> {pkg.credits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold text-primary">
|
||||||
|
{formatPrice(pkg.price, 'USD')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground text-left py-2 flex items-center gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||||
|
{pkg.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* purchase button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePurchase(pkg.id)}
|
||||||
|
disabled={loadingPackage === pkg.id}
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
variant={pkg.popular ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{loadingPackage === pkg.id ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Purchase'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment Dialog */}
|
||||||
|
<Dialog open={paymentDialog.isOpen} onOpenChange={handlePaymentCancel}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Complete Your Purchase</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{paymentDialog.clientSecret && paymentDialog.packageId && (
|
||||||
|
<StripePaymentForm
|
||||||
|
clientSecret={paymentDialog.clientSecret}
|
||||||
|
packageId={paymentDialog.packageId}
|
||||||
|
packageInfo={getPackageInfo(paymentDialog.packageId)!}
|
||||||
|
onPaymentSuccess={handlePaymentSuccess}
|
||||||
|
onPaymentCancel={handlePaymentCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
191
src/components/settings/credits/stripe-payment-form.tsx
Normal file
191
src/components/settings/credits/stripe-payment-form.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { confirmCreditPayment } from '@/actions/credits.action';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { formatPrice } from '@/lib/formatter';
|
||||||
|
import {
|
||||||
|
Elements,
|
||||||
|
PaymentElement,
|
||||||
|
useElements,
|
||||||
|
useStripe,
|
||||||
|
} from '@stripe/react-stripe-js';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface StripePaymentFormProps {
|
||||||
|
clientSecret: string;
|
||||||
|
packageId: string;
|
||||||
|
packageInfo: {
|
||||||
|
credits: number;
|
||||||
|
price: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
onPaymentSuccess: () => void;
|
||||||
|
onPaymentCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StripePaymentForm(props: StripePaymentFormProps) {
|
||||||
|
const { resolvedTheme: theme } = useTheme();
|
||||||
|
const stripePromise = useMemo(() => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||||
|
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
|
||||||
|
}
|
||||||
|
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const options = useMemo(() => ({
|
||||||
|
clientSecret: props.clientSecret,
|
||||||
|
appearance: {
|
||||||
|
theme: (theme === "dark" ? "night" : "stripe") as "night" | "stripe",
|
||||||
|
},
|
||||||
|
loader: 'auto' as const,
|
||||||
|
}), [props.clientSecret, theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Elements stripe={stripePromise} options={options}>
|
||||||
|
<PaymentForm {...props} />
|
||||||
|
</Elements>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentFormProps {
|
||||||
|
clientSecret: string;
|
||||||
|
packageId: string;
|
||||||
|
packageInfo: {
|
||||||
|
credits: number;
|
||||||
|
price: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
onPaymentSuccess: () => void;
|
||||||
|
onPaymentCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentForm({
|
||||||
|
clientSecret,
|
||||||
|
packageId,
|
||||||
|
packageInfo,
|
||||||
|
onPaymentSuccess,
|
||||||
|
onPaymentCancel,
|
||||||
|
}: PaymentFormProps) {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!stripe || !elements) {
|
||||||
|
console.error('Stripe or elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Confirm the payment using PaymentElement
|
||||||
|
const { error } = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
redirect: "if_required",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('PaymentForm, payment error:', error);
|
||||||
|
throw new Error(error.message || "Payment failed");
|
||||||
|
} else {
|
||||||
|
// The payment was successful
|
||||||
|
const paymentIntent = await stripe.retrievePaymentIntent(clientSecret);
|
||||||
|
if (paymentIntent.paymentIntent) {
|
||||||
|
const result = await confirmCreditPayment({
|
||||||
|
packageId,
|
||||||
|
paymentIntentId: paymentIntent.paymentIntent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.data?.success) {
|
||||||
|
console.log('PaymentForm, payment success');
|
||||||
|
toast.success(`${packageInfo.credits} credits have been added to your account.`);
|
||||||
|
onPaymentSuccess();
|
||||||
|
} else {
|
||||||
|
console.error('PaymentForm, payment error:', result?.data?.error);
|
||||||
|
throw new Error(
|
||||||
|
result?.data?.error ||
|
||||||
|
result?.serverError ||
|
||||||
|
'Failed to confirm payment'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('PaymentForm, no payment intent found');
|
||||||
|
throw new Error("No payment intent found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PaymentForm, payment error:', error);
|
||||||
|
toast.error('Purchase credits failed');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CoinsIcon className="h-4 w-4" />
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{packageInfo.credits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-primary">
|
||||||
|
{formatPrice(packageInfo.price, 'USD')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-xs text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
We use Stripe, a trusted global payment provider, to process your payment.
|
||||||
|
For your security, your payment details are handled directly by Stripe and never touch our servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<PaymentElement />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPaymentCancel}
|
||||||
|
disabled={processing}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || !stripe || !elements}
|
||||||
|
className="px-8 cursor-pointer"
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Pay {formatPrice(packageInfo.price, 'USD')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -5,6 +5,7 @@ import type { NestedMenuItem } from '@/types';
|
|||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
CircleUserRoundIcon,
|
CircleUserRoundIcon,
|
||||||
|
CoinsIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
LockKeyholeIcon,
|
LockKeyholeIcon,
|
||||||
@ -66,6 +67,12 @@ export function getSidebarLinks(): NestedMenuItem[] {
|
|||||||
href: Routes.SettingsBilling,
|
href: Routes.SettingsBilling,
|
||||||
external: false,
|
external: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.credits.title'),
|
||||||
|
icon: <CoinsIcon className="size-4 shrink-0" />,
|
||||||
|
href: Routes.SettingsCredits,
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('settings.security.title'),
|
title: t('settings.security.title'),
|
||||||
icon: <LockKeyholeIcon className="size-4 shrink-0" />,
|
icon: <LockKeyholeIcon className="size-4 shrink-0" />,
|
||||||
|
@ -1,12 +1,37 @@
|
|||||||
export const PLACEHOLDER_IMAGE =
|
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==';
|
'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==';
|
||||||
|
|
||||||
// credit package definition (example)
|
// credit package definition (price in cents)
|
||||||
export const CREDIT_PACKAGES = [
|
export const CREDIT_PACKAGES = [
|
||||||
{ id: 'package-1', credits: 1000, price: 10 },
|
{
|
||||||
{ id: 'package-2', credits: 2500, price: 20 },
|
id: 'basic',
|
||||||
{ id: 'package-3', credits: 5000, price: 30 },
|
credits: 100,
|
||||||
];
|
price: 990, // 9.90 USD in cents
|
||||||
|
popular: false,
|
||||||
|
description: 'Perfect for getting started',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standard',
|
||||||
|
credits: 200,
|
||||||
|
price: 1490, // 14.90 USD in cents
|
||||||
|
popular: true,
|
||||||
|
description: 'Most popular package',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'premium',
|
||||||
|
credits: 500,
|
||||||
|
price: 3990, // 39.90 USD in cents
|
||||||
|
popular: false,
|
||||||
|
description: 'Best value for heavy users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise',
|
||||||
|
credits: 1000,
|
||||||
|
price: 6990, // 69.90 USD in cents
|
||||||
|
popular: false,
|
||||||
|
description: 'Tailored for enterprises',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
// free monthly credits (10% of the smallest package)
|
// free monthly credits (10% of the smallest package)
|
||||||
export const FREE_MONTHLY_CREDITS = 50;
|
export const FREE_MONTHLY_CREDITS = 50;
|
||||||
|
@ -2,8 +2,11 @@ import { websiteConfig } from '@/config/website';
|
|||||||
import { StripeProvider } from './provider/stripe';
|
import { StripeProvider } from './provider/stripe';
|
||||||
import type {
|
import type {
|
||||||
CheckoutResult,
|
CheckoutResult,
|
||||||
|
ConfirmPaymentIntentParams,
|
||||||
CreateCheckoutParams,
|
CreateCheckoutParams,
|
||||||
|
CreatePaymentIntentParams,
|
||||||
CreatePortalParams,
|
CreatePortalParams,
|
||||||
|
PaymentIntentResult,
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
PortalResult,
|
PortalResult,
|
||||||
Subscription,
|
Subscription,
|
||||||
@ -92,3 +95,27 @@ export const getSubscriptions = async (
|
|||||||
const provider = getPaymentProvider();
|
const provider = getPaymentProvider();
|
||||||
return provider.getSubscriptions(params);
|
return provider.getSubscriptions(params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a payment intent
|
||||||
|
* @param params Parameters for creating the payment intent
|
||||||
|
* @returns Payment intent result
|
||||||
|
*/
|
||||||
|
export const createPaymentIntent = async (
|
||||||
|
params: CreatePaymentIntentParams
|
||||||
|
): Promise<PaymentIntentResult> => {
|
||||||
|
const provider = getPaymentProvider();
|
||||||
|
return provider.createPaymentIntent(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a payment intent
|
||||||
|
* @param params Parameters for confirming the payment intent
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
export const confirmPaymentIntent = async (
|
||||||
|
params: ConfirmPaymentIntentParams
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const provider = getPaymentProvider();
|
||||||
|
return provider.confirmPaymentIntent(params);
|
||||||
|
};
|
||||||
|
@ -7,12 +7,17 @@ import {
|
|||||||
findPriceInPlan,
|
findPriceInPlan,
|
||||||
} from '@/lib/price-plan';
|
} from '@/lib/price-plan';
|
||||||
import { sendNotification } from '@/notification/notification';
|
import { sendNotification } from '@/notification/notification';
|
||||||
|
import { addCredits } from '@/lib/credits';
|
||||||
|
import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
import { Stripe } from 'stripe';
|
import { Stripe } from 'stripe';
|
||||||
import {
|
import {
|
||||||
type CheckoutResult,
|
type CheckoutResult,
|
||||||
|
type ConfirmPaymentIntentParams,
|
||||||
type CreateCheckoutParams,
|
type CreateCheckoutParams,
|
||||||
|
type CreatePaymentIntentParams,
|
||||||
type CreatePortalParams,
|
type CreatePortalParams,
|
||||||
|
type PaymentIntentResult,
|
||||||
type PaymentProvider,
|
type PaymentProvider,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
PaymentTypes,
|
PaymentTypes,
|
||||||
@ -397,6 +402,12 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
await this.onOnetimePayment(session);
|
await this.onOnetimePayment(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (eventType.startsWith('payment_intent.')) {
|
||||||
|
// Handle payment intent events
|
||||||
|
if (eventType === 'payment_intent.succeeded') {
|
||||||
|
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||||
|
await this.onPaymentIntentSucceeded(paymentIntent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('handle webhook event error:', error);
|
console.error('handle webhook event error:', error);
|
||||||
@ -632,6 +643,97 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
await sendNotification(session.id, customerId, userId, amount);
|
await sendNotification(session.id, customerId, userId, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment intent succeeded event
|
||||||
|
* @param paymentIntent Stripe payment intent
|
||||||
|
*/
|
||||||
|
private async onPaymentIntentSucceeded(
|
||||||
|
paymentIntent: Stripe.PaymentIntent
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`>> Handle payment intent succeeded: ${paymentIntent.id}`);
|
||||||
|
|
||||||
|
// Get metadata from payment intent
|
||||||
|
const { packageId, userId, credits } = paymentIntent.metadata;
|
||||||
|
|
||||||
|
if (!packageId || !userId || !credits) {
|
||||||
|
console.warn(
|
||||||
|
`<< Missing metadata for payment intent ${paymentIntent.id}: packageId=${packageId}, userId=${userId}, credits=${credits}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add credits to user account using existing addCredits method
|
||||||
|
await addCredits({
|
||||||
|
userId,
|
||||||
|
amount: parseInt(credits),
|
||||||
|
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
|
||||||
|
description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`,
|
||||||
|
paymentId: paymentIntent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`<< Error processing payment intent ${paymentIntent.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a payment intent
|
||||||
|
* @param params Parameters for creating the payment intent
|
||||||
|
* @returns Payment intent result
|
||||||
|
*/
|
||||||
|
public async createPaymentIntent(
|
||||||
|
params: CreatePaymentIntentParams
|
||||||
|
): Promise<PaymentIntentResult> {
|
||||||
|
const { amount, currency, metadata } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
metadata,
|
||||||
|
automatic_payment_methods: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: paymentIntent.id,
|
||||||
|
clientSecret: paymentIntent.client_secret!,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create payment intent error:', error);
|
||||||
|
throw new Error('Failed to create payment intent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a payment intent
|
||||||
|
* @param params Parameters for confirming the payment intent
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
public async confirmPaymentIntent(
|
||||||
|
params: ConfirmPaymentIntentParams
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { paymentIntentId } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
||||||
|
|
||||||
|
return paymentIntent.status === 'succeeded';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Confirm payment intent error:', error);
|
||||||
|
throw new Error('Failed to confirm payment intent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Stripe subscription interval to our own interval types
|
* Map Stripe subscription interval to our own interval types
|
||||||
* @param subscription Stripe subscription
|
* @param subscription Stripe subscription
|
||||||
|
@ -159,6 +159,30 @@ export interface getSubscriptionsParams {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for creating a payment intent
|
||||||
|
*/
|
||||||
|
export interface CreatePaymentIntentParams {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of creating a payment intent
|
||||||
|
*/
|
||||||
|
export interface PaymentIntentResult {
|
||||||
|
id: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for confirming a payment intent
|
||||||
|
*/
|
||||||
|
export interface ConfirmPaymentIntentParams {
|
||||||
|
paymentIntentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payment provider interface
|
* Payment provider interface
|
||||||
*/
|
*/
|
||||||
@ -178,6 +202,16 @@ export interface PaymentProvider {
|
|||||||
*/
|
*/
|
||||||
getSubscriptions(params: getSubscriptionsParams): Promise<Subscription[]>;
|
getSubscriptions(params: getSubscriptionsParams): Promise<Subscription[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a payment intent
|
||||||
|
*/
|
||||||
|
createPaymentIntent(params: CreatePaymentIntentParams): Promise<PaymentIntentResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a payment intent
|
||||||
|
*/
|
||||||
|
confirmPaymentIntent(params: ConfirmPaymentIntentParams): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle webhook events
|
* Handle webhook events
|
||||||
*/
|
*/
|
||||||
|
@ -33,6 +33,7 @@ export enum Routes {
|
|||||||
AdminUsers = '/admin/users',
|
AdminUsers = '/admin/users',
|
||||||
SettingsProfile = '/settings/profile',
|
SettingsProfile = '/settings/profile',
|
||||||
SettingsBilling = '/settings/billing',
|
SettingsBilling = '/settings/billing',
|
||||||
|
SettingsCredits = '/settings/credits',
|
||||||
SettingsSecurity = '/settings/security',
|
SettingsSecurity = '/settings/security',
|
||||||
SettingsNotifications = '/settings/notifications',
|
SettingsNotifications = '/settings/notifications',
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ export const protectedRoutes = [
|
|||||||
Routes.AdminUsers,
|
Routes.AdminUsers,
|
||||||
Routes.SettingsProfile,
|
Routes.SettingsProfile,
|
||||||
Routes.SettingsBilling,
|
Routes.SettingsBilling,
|
||||||
|
Routes.SettingsCredits,
|
||||||
Routes.SettingsSecurity,
|
Routes.SettingsSecurity,
|
||||||
Routes.SettingsNotifications,
|
Routes.SettingsNotifications,
|
||||||
];
|
];
|
||||||
|
Loading…
Reference in New Issue
Block a user