feat: stripe integration

This commit is contained in:
Vasily Zubarev
2025-04-24 15:27:44 +02:00
parent 38a5c0f814
commit abd5ad8403
31 changed files with 559 additions and 112 deletions

View File

@@ -7,8 +7,8 @@ import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
import { useState } from "react"
export function LoginForm() {
const [email, setEmail] = useState("")
export function LoginForm({ defaultEmail }: { defaultEmail?: string }) {
const [email, setEmail] = useState(defaultEmail || "")
const [otp, setOtp] = useState("")
const [isOtpSent, setIsOtpSent] = useState(false)
const [isLoading, setIsLoading] = useState(false)

View File

@@ -0,0 +1,62 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Plan } from "@/lib/stripe"
import { Check, Loader2 } from "lucide-react"
import { useState } from "react"
import { FormError } from "../forms/error"
export function PricingCard({ plan, hideButton = false }: { plan: Plan; hideButton?: boolean }) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleClick = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/stripe/checkout?code=${plan.code}`, {
method: "POST",
})
const data = await response.json()
if (data.error) {
setError(data.error)
} else {
window.location.href = data.session.url
}
} catch (error) {
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsLoading(false)
}
}
return (
<Card className="w-full max-w-xs relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-secondary/10" />
<CardHeader className="relative">
<CardTitle className="text-3xl">{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
{plan.price && <div className="text-2xl font-bold mt-4">{plan.price}</div>}
</CardHeader>
<CardContent className="relative">
<ul className="space-y-2">
{plan.benefits.map((benefit, index) => (
<li key={index} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
<span>{benefit}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex flex-col gap-2 relative">
{!hideButton && (
<Button className="w-full" onClick={handleClick} disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Get Started"}
</Button>
)}
{error && <FormError>{error}</FormError>}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,12 @@
import Link from "next/link"
export function SubscriptionExpired() {
return (
<Link
href="/settings/profile"
className="w-full h-8 p-1 bg-red-500 text-white font-semibold text-center hover:bg-red-600 transition-colors"
>
Your subscription has expired. Click here to select a new plan. Otherwise, your account will be deleted.
</Link>
)
}

View File

@@ -4,11 +4,10 @@ import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { formatBytes, formatNumber } from "@/lib/utils"
import { User } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
import { SubscriptionPlan } from "./subscription-plan"
export default function ProfileSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
@@ -32,13 +31,10 @@ export default function ProfileSettingsForm({ user }: { user: User }) {
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
<Card className="mt-4 p-4">
<p>
Storage Used: {formatBytes(user.storageUsed)} /{" "}
{user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"}
</p>
<p>Tokens Balance: {formatNumber(user.tokenBalance)}</p>
</Card>
<div className="mt-10">
<SubscriptionPlan user={user} />
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { User } from "@prisma/client"
import { PricingCard } from "@/components/auth/pricing-card"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { PLANS } from "@/lib/stripe"
import { formatBytes, formatNumber } from "@/lib/utils"
import { formatDate } from "date-fns"
import { BrainCog, CalendarSync, HardDrive } from "lucide-react"
import Link from "next/link"
import { Badge } from "../ui/badge"
export function SubscriptionPlan({ user }: { user: User }) {
const plan = PLANS[user.membershipPlan as keyof typeof PLANS] || PLANS.unlimited
return (
<div className="flex flex-wrap gap-5">
<div className="flex flex-col gap-2 flex-1 items-center justify-center max-w-[300px]">
<PricingCard plan={plan} hideButton={true} />
<Badge variant="outline">Current Plan</Badge>
</div>
<div className="flex-1">
<Card className="w-full p-4">
<div className="space-y-2">
<strong className="text-lg">Usage:</strong>
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
<span>
<strong className="font-semibold">Storage:</strong> {formatBytes(user.storageUsed)} /{" "}
{user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"}
</span>
</div>
<div className="flex items-center gap-2">
<BrainCog className="h-4 w-4" />
<span>
<strong className="font-semibold">AI Scans:</strong> {formatNumber(plan.limits.ai - user.aiBalance)} /{" "}
{plan.limits.ai > 0 ? formatNumber(plan.limits.ai) : "Unlimited"}
</span>
</div>
<div className="flex items-center gap-2">
<CalendarSync className="h-4 w-4" />
<span>
<strong className="font-semibold">Expiration Date:</strong>{" "}
{user.membershipExpiresAt ? formatDate(user.membershipExpiresAt, "yyyy-MM-dd") : "Never"}
</span>
</div>
</div>
{user.stripeCustomerId && (
<div className="space-y-4 mt-6">
<Button asChild className="w-full">
<Link href="/api/stripe/portal">Manage Subscription</Link>
</Button>
</div>
)}
</Card>
</div>
</div>
)
}