Skip to content

Commit b2594f6

Browse files
authored
Merge pull request #780 from designsage8/fix/image-opt-billing-logger-750-767-768
feat: image optimization, billing management, and logger fixes (close…
2 parents fbc5b3e + 630478c commit b2594f6

21 files changed

Lines changed: 813 additions & 46 deletions

File tree

app/(dashboard)/billing/page.tsx

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"use client";
2+
3+
import React, { useEffect, useState } from "react";
4+
import { useSearchParams } from "next/navigation";
5+
import { Check, Zap, Sparkles, Shield, ArrowRight, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
6+
import {
7+
useUserStore,
8+
selectUserPlan,
9+
selectPlanUsage,
10+
selectTransformQuotaRemaining,
11+
} from "@/app/store/userStore";
12+
import type { BillingPlan } from "@/app/api/billing/plans/route";
13+
14+
export default function BillingPage() {
15+
const searchParams = useSearchParams();
16+
const currentPlan = useUserStore(selectUserPlan);
17+
const planUsagePercent = useUserStore(selectPlanUsage);
18+
const quotaRemaining = useUserStore(selectTransformQuotaRemaining);
19+
const fetchUser = useUserStore((s) => s.fetchUser);
20+
21+
const [plans, setPlans] = useState<BillingPlan[]>([]);
22+
const [loadingPlans, setLoadingPlans] = useState(true);
23+
const [upgradingPlanId, setUpgradingPlanId] = useState<string | null>(null);
24+
const [error, setError] = useState<string | null>(null);
25+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
26+
27+
const isSuccess = searchParams.get("success") === "true";
28+
const upgradedPlanParam = searchParams.get("plan");
29+
30+
useEffect(() => {
31+
fetchUser();
32+
fetch("/api/billing/plans")
33+
.then((res) => res.json())
34+
.then((data) => {
35+
if (data.plans) {
36+
setPlans(data.plans);
37+
}
38+
})
39+
.catch((err) => setError("Failed to load pricing plans."))
40+
.finally(() => setLoadingPlans(false));
41+
}, [fetchUser]);
42+
43+
useEffect(() => {
44+
if (isSuccess && upgradedPlanParam) {
45+
setSuccessMessage(
46+
`Successfully upgraded to ${upgradedPlanParam.toUpperCase()} plan! Your transform quota has been updated.`
47+
);
48+
fetchUser();
49+
}
50+
}, [isSuccess, upgradedPlanParam, fetchUser]);
51+
52+
const handleUpgrade = async (planId: string) => {
53+
if (planId === currentPlan) return;
54+
setUpgradingPlanId(planId);
55+
setError(null);
56+
57+
try {
58+
const res = await fetch("/api/billing/checkout", {
59+
method: "POST",
60+
headers: { "Content-Type": "application/json" },
61+
body: JSON.stringify({ planId }),
62+
});
63+
64+
const data = await res.json();
65+
if (!res.ok || !data.url) {
66+
throw new Error(data.error || "Failed to initiate checkout");
67+
}
68+
69+
// Redirect to checkout URL
70+
window.location.href = data.url;
71+
} catch (err) {
72+
setError(err instanceof Error ? err.message : "Checkout error");
73+
setUpgradingPlanId(null);
74+
}
75+
};
76+
77+
return (
78+
<div className="max-w-[1200px] mx-auto space-y-10 py-6">
79+
{/* Header */}
80+
<div className="space-y-2">
81+
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand/10 border border-brand/20 text-brand text-xs font-bold uppercase tracking-wider">
82+
<Zap className="w-3.5 h-3.5" />
83+
Subscription & Billing
84+
</div>
85+
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight">
86+
Manage Your Plan & Quotas
87+
</h1>
88+
<p className="text-muted text-base max-w-2xl">
89+
Scale your viral video clip engine. Upgrade anytime to unlock higher AI transform quotas, 4K/8K export rendering, and priority GPU processing.
90+
</p>
91+
</div>
92+
93+
{/* Notifications */}
94+
{successMessage && (
95+
<div className="p-4 rounded-2xl bg-green-500/10 border border-green-500/20 text-green-400 flex items-center gap-3 text-sm font-semibold animate-in fade-in">
96+
<CheckCircle2 className="w-5 h-5 shrink-0" />
97+
<span>{successMessage}</span>
98+
</div>
99+
)}
100+
{error && (
101+
<div className="p-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 flex items-center gap-3 text-sm font-semibold animate-in fade-in">
102+
<AlertCircle className="w-5 h-5 shrink-0" />
103+
<span>{error}</span>
104+
</div>
105+
)}
106+
107+
{/* Current Plan Summary Card */}
108+
<div className="bg-surface border border-white/10 rounded-3xl p-6 md:p-8 space-y-6">
109+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-white/5">
110+
<div className="space-y-1">
111+
<span className="text-xs font-bold text-muted uppercase tracking-wider">Active Plan</span>
112+
<div className="flex items-center gap-3">
113+
<h2 className="text-2xl font-black text-white capitalize">{currentPlan} Plan</h2>
114+
<span className="px-3 py-0.5 rounded-full bg-brand/10 border border-brand/20 text-brand text-xs font-bold uppercase">
115+
Active
116+
</span>
117+
</div>
118+
</div>
119+
120+
<div className="flex items-center gap-6">
121+
<div className="text-right">
122+
<span className="text-xs text-muted block">Remaining Quota</span>
123+
<span className="text-xl font-extrabold text-brand">{quotaRemaining} Transforms</span>
124+
</div>
125+
</div>
126+
</div>
127+
128+
{/* Live Usage Progress Bar */}
129+
<div className="space-y-2">
130+
<div className="flex justify-between items-center text-sm font-bold">
131+
<span className="text-white">Monthly Quota Consumption</span>
132+
<span className={planUsagePercent >= 90 ? "text-red-400" : "text-brand"}>
133+
{planUsagePercent}% Used
134+
</span>
135+
</div>
136+
<div className="relative h-3 w-full bg-input rounded-full overflow-hidden border border-white/5">
137+
<div
138+
className={`absolute top-0 left-0 h-full rounded-full transition-all duration-500 ${
139+
planUsagePercent >= 90
140+
? "bg-red-500 shadow-[0_0_12px_rgba(239,68,68,0.5)]"
141+
: planUsagePercent >= 70
142+
? "bg-yellow-400 shadow-[0_0_12px_rgba(250,204,21,0.5)]"
143+
: "bg-brand shadow-[0_0_12px_rgba(0,229,143,0.5)]"
144+
}`}
145+
style={{ width: `${Math.min(100, Math.max(0, planUsagePercent))}%` }}
146+
/>
147+
</div>
148+
<p className="text-xs text-muted">
149+
Quotas reset at the beginning of each billing cycle. Upgrading immediately adds new transform capacity.
150+
</p>
151+
</div>
152+
</div>
153+
154+
{/* Plans Comparison Grid */}
155+
<div className="space-y-6">
156+
<h2 className="text-2xl font-extrabold text-white tracking-tight">Available Plans</h2>
157+
158+
{loadingPlans ? (
159+
<div className="py-16 flex items-center justify-center">
160+
<Loader2 className="w-8 h-8 text-brand animate-spin" />
161+
</div>
162+
) : (
163+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
164+
{plans.map((p) => {
165+
const isCurrent = currentPlan === p.id;
166+
const isUpgrading = upgradingPlanId === p.id;
167+
168+
return (
169+
<div
170+
key={p.id}
171+
className={`relative bg-surface border rounded-3xl p-6 flex flex-col justify-between transition-all duration-200 ${
172+
p.popular
173+
? "border-brand/50 shadow-[0_0_30px_rgba(0,229,143,0.15)] bg-surface/90"
174+
: "border-white/10 hover:border-white/20"
175+
}`}
176+
>
177+
{p.popular && (
178+
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-brand text-black font-extrabold text-[11px] uppercase tracking-wider px-3.5 py-1 rounded-full shadow-lg flex items-center gap-1">
179+
<Sparkles className="w-3 h-3 fill-black" /> Most Popular
180+
</div>
181+
)}
182+
183+
<div className="space-y-5">
184+
<div>
185+
<h3 className="text-xl font-bold text-white mb-1">{p.name}</h3>
186+
<p className="text-muted text-xs min-h-[36px]">{p.description}</p>
187+
</div>
188+
189+
<div className="flex items-baseline gap-1">
190+
<span className="text-4xl font-black text-white">${p.price}</span>
191+
<span className="text-muted text-xs font-semibold">/{p.interval}</span>
192+
</div>
193+
194+
<div className="space-y-2.5 pt-2 border-t border-white/5">
195+
<span className="text-xs font-bold text-white/80 block">Included Features:</span>
196+
{p.features.map((feat, idx) => (
197+
<div key={idx} className="flex items-start gap-2 text-xs text-muted">
198+
<Check className="w-4 h-4 text-brand shrink-0 mt-0.5" />
199+
<span>{feat}</span>
200+
</div>
201+
))}
202+
</div>
203+
</div>
204+
205+
<div className="pt-6 mt-6 border-t border-white/5">
206+
{isCurrent ? (
207+
<button
208+
disabled
209+
className="w-full py-3 rounded-xl text-xs font-bold bg-white/5 text-white/50 border border-white/5 cursor-default flex items-center justify-center gap-2"
210+
>
211+
<Check className="w-4 h-4" /> Current Plan
212+
</button>
213+
) : (
214+
<button
215+
onClick={() => handleUpgrade(p.id)}
216+
disabled={!!upgradingPlanId}
217+
className={`w-full py-3 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 ${
218+
p.popular
219+
? "bg-brand text-black hover:bg-brand-hover shadow-[0_0_20px_rgba(0,229,143,0.3)]"
220+
: "bg-white/10 hover:bg-white/20 text-white border border-white/10"
221+
} disabled:opacity-50`}
222+
>
223+
{isUpgrading ? (
224+
<>
225+
<Loader2 className="w-4 h-4 animate-spin" /> Redirecting...
226+
</>
227+
) : (
228+
<>
229+
Upgrade to {p.name} <ArrowRight className="w-4 h-4" />
230+
</>
231+
)}
232+
</button>
233+
)}
234+
</div>
235+
</div>
236+
);
237+
})}
238+
</div>
239+
)}
240+
</div>
241+
242+
{/* Security Guarantee */}
243+
<div className="bg-input border border-white/5 rounded-2xl p-6 flex flex-col md:flex-row items-center gap-4 text-center md:text-left justify-between">
244+
<div className="flex items-center gap-3">
245+
<Shield className="w-6 h-6 text-brand shrink-0" />
246+
<div className="text-xs text-muted">
247+
<span className="font-bold text-white block">Secure Stripe Checkout</span>
248+
Encrypted payment processing. Upgrade or cancel anytime from your billing dashboard.
249+
</div>
250+
</div>
251+
</div>
252+
</div>
253+
);
254+
}

app/(dashboard)/transform/[id]/page.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React, { useEffect, useRef, useState } from "react";
44
import Link from "next/link";
5+
import Image from "next/image";
56
import { useParams, useRouter } from "next/navigation";
67
import {
78
AlertCircle,
@@ -341,13 +342,13 @@ export default function TransformProgressPage() {
341342
<div className="bg-surface border border-white/5 rounded-3xl p-8 space-y-7">
342343
{/* Clip thumbnail placeholder */}
343344
<div className="flex items-center gap-4">
344-
<div className="w-20 h-14 rounded-xl bg-input border border-white/5 flex items-center justify-center shrink-0">
345+
<div className="relative w-20 h-14 rounded-xl bg-input border border-white/5 flex items-center justify-center shrink-0 overflow-hidden">
345346
{previewUrl ? (
346-
// eslint-disable-next-line @next/next/no-img-element
347-
<img
347+
<Image
348348
src={previewUrl}
349349
alt="Latest preview frame"
350-
className="w-full h-full object-cover rounded-xl"
350+
fill
351+
className="object-cover rounded-xl"
351352
/>
352353
) : (
353354
<Loader2 className="w-5 h-5 text-muted-foreground animate-spin" />
@@ -393,12 +394,12 @@ export default function TransformProgressPage() {
393394
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
394395
Latest Preview Frame
395396
</p>
396-
<div className="rounded-2xl overflow-hidden border border-white/10 bg-black aspect-video flex items-center justify-center">
397-
{/* eslint-disable-next-line @next/next/no-img-element */}
398-
<img
397+
<div className="relative rounded-2xl overflow-hidden border border-white/10 bg-black aspect-video flex items-center justify-center">
398+
<Image
399399
src={previewUrl}
400400
alt="Preview frame from AI transformation"
401-
className="w-full h-full object-contain"
401+
fill
402+
className="object-contain"
402403
/>
403404
</div>
404405
</div>

app/api/billing/checkout/route.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getServerSession } from "next-auth";
3+
import { authOptions } from "@/app/lib/auth";
4+
import { checkCsrf } from "@/app/lib/csrf";
5+
import { parseRequestJson } from "@/app/lib/parseRequestJson";
6+
import { applyRateLimit } from "@/app/lib/serverRateLimit";
7+
import { logger } from "@/app/lib/logger";
8+
9+
export async function POST(request: NextRequest) {
10+
const rateLimited = await applyRateLimit(request, { limit: 10, windowMs: 60_000 });
11+
if (rateLimited) return rateLimited;
12+
13+
const csrfError = checkCsrf(request);
14+
if (csrfError) return csrfError;
15+
16+
const session = await getServerSession(authOptions);
17+
if (!session?.user?.email) {
18+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
19+
}
20+
21+
const parsed = await parseRequestJson(request);
22+
if (!parsed.ok) return parsed.response;
23+
const body = parsed.body as { planId?: string };
24+
25+
const planId = body?.planId;
26+
if (!planId || !["pro", "enterprise"].includes(planId)) {
27+
return NextResponse.json(
28+
{ error: "Invalid planId. Must be 'pro' or 'enterprise'." },
29+
{ status: 400 }
30+
);
31+
}
32+
33+
const stripeKey = process.env.STRIPE_SECRET_KEY;
34+
const origin =
35+
process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
36+
`${request.nextUrl.protocol}//${request.nextUrl.host}`;
37+
38+
if (stripeKey) {
39+
try {
40+
// Dynamic import of Stripe to handle optional runtime dependency
41+
const Stripe = (await import("stripe")).default;
42+
const stripe = new Stripe(stripeKey, { apiVersion: "2023-10-16" as any });
43+
44+
const priceId =
45+
planId === "pro"
46+
? process.env.STRIPE_PRO_PRICE_ID
47+
: process.env.STRIPE_ENTERPRISE_PRICE_ID;
48+
49+
const checkoutSession = await stripe.checkout.sessions.create({
50+
mode: "subscription",
51+
payment_method_types: ["card"],
52+
customer_email: session.user.email,
53+
line_items: priceId
54+
? [{ price: priceId, quantity: 1 }]
55+
: [
56+
{
57+
price_data: {
58+
currency: "usd",
59+
product_data: {
60+
name: `ClipsAI ${planId.toUpperCase()} Plan`,
61+
description: `Monthly subscription to ClipsAI ${planId} plan`,
62+
},
63+
unit_amount: planId === "pro" ? 2900 : 9900,
64+
recurring: { interval: "month" },
65+
},
66+
quantity: 1,
67+
},
68+
],
69+
metadata: {
70+
userEmail: session.user.email,
71+
plan: planId,
72+
},
73+
success_url: `${origin}/billing?success=true&plan=${planId}`,
74+
cancel_url: `${origin}/billing?canceled=true`,
75+
});
76+
77+
logger.info(`[billing] Created Stripe checkout session ${checkoutSession.id} for ${session.user.email}`);
78+
79+
return NextResponse.json({
80+
url: checkoutSession.url,
81+
sessionId: checkoutSession.id,
82+
});
83+
} catch (err) {
84+
logger.error(`[billing] Stripe checkout session error: ${err instanceof Error ? err.message : String(err)}`);
85+
}
86+
}
87+
88+
// Fallback test/development response when Stripe key is not configured
89+
logger.info(`[billing] Simulating checkout for plan ${planId} for user ${session.user.email}`);
90+
const redirectUrl = `${origin}/billing?success=true&plan=${planId}&simulated=true`;
91+
92+
return NextResponse.json({
93+
url: redirectUrl,
94+
sessionId: `cs_simulated_${Date.now()}`,
95+
simulated: true,
96+
});
97+
}

0 commit comments

Comments
 (0)