diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0ce5eea --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "terminal.integrated.sendKeybindingsToShell": true +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 230173f..fd389e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: # ================================ diff --git a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index c983e1d..5986cc8 100644 --- a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -9,10 +9,11 @@ export default function Page() { appearance={{ baseTheme: dark }} + redirectUrl="/" />
- +
); diff --git a/src/app/(dashboard)/generate-pro/page.tsx b/src/app/(dashboard)/generate-pro/page.tsx index 251abb6..1d3a0dc 100644 --- a/src/app/(dashboard)/generate-pro/page.tsx +++ b/src/app/(dashboard)/generate-pro/page.tsx @@ -1,13 +1,25 @@ -import Generate from "@/components/core/generate"; +import FlashcardPro from "@/components/core/flash-card-pro"; +import { auth } from '@clerk/nextjs/server'; +import { subscriptionService } from '@/lib/services/subscription.service'; +import { redirect } from 'next/navigation'; -export default function GeneratePage() { - return ( - //
- //

Generate Flashcards

- // - //
-
- -
- ); +export default async function GenerateProPage() { + const { userId } = await auth(); + + if (!userId) { + redirect('/sign-in?redirect_url=/generate-pro'); + } + + // Check if user has Pro access + const canAccessPro = await subscriptionService.canAccessProFeatures(userId); + + if (!canAccessPro) { + redirect('/pricing?upgrade=required&redirect=/generate-pro'); + } + + return ( +
+ +
+ ); } \ No newline at end of file diff --git a/src/app/api/checkout-sessions/route.ts b/src/app/api/checkout-sessions/route.ts index e0aa930..765b72b 100644 --- a/src/app/api/checkout-sessions/route.ts +++ b/src/app/api/checkout-sessions/route.ts @@ -1,65 +1,14 @@ -import { NextResponse } from "next/server"; -import Stripe from "stripe"; - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2024-06-20", -}); - -export async function POST(req: Request) { - const { subscriptionType } = await req.json(); - - const priceId = - subscriptionType === "yearly" - ? process.env.STRIPE_PRICE_YEARLY - : process.env.STRIPE_PRICE_MONTHLY; - - if (!priceId) { - return NextResponse.json( - { error: "Invalid subscription type" }, - { status: 400 }, - ); - } - - try { - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/result?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`, - }); - - return NextResponse.json({ sessionId: session.id }); - } catch (error) { - console.error("Error creating checkout session:", error); - return NextResponse.json( - { error: "Error creating checkout session" }, - { status: 500 }, - ); - } +// Stripe support removed in favor of Razorpay. +export async function POST() { + return new Response(JSON.stringify({ error: 'Stripe removed. Use Razorpay.' }), { + status: 410, + headers: { 'Content-Type': 'application/json' } + }) } -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const session_id = searchParams.get("session_id"); - - if (!session_id) { - return NextResponse.json({ error: "Missing session_id" }, { status: 400 }); - } - - try { - const session = await stripe.checkout.sessions.retrieve(session_id); - return NextResponse.json(session); - } catch (error) { - console.error("Error retrieving checkout session:", error); - return NextResponse.json( - { error: "Error retrieving checkout session" }, - { status: 500 }, - ); - } +export async function GET() { + return new Response(JSON.stringify({ error: 'Stripe removed. Use Razorpay.' }), { + status: 410, + headers: { 'Content-Type': 'application/json' } + }) } diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 71da0f0..57df88f 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { flashcardService } from "@/lib/services/flashcard.service"; +import { prisma } from "@/lib/database"; +import { subscriptionService } from "@/lib/services/subscription.service"; import type { ApiError, FlashcardGenerationResponse } from "@/types"; export async function POST(req: Request) { @@ -19,7 +21,43 @@ export async function POST(req: Request) { return NextResponse.json(error, { status: 400 }); } - // ✅ CORRECT: Single Responsibility - Delegate to service + // Determine plan-based limits + const status = await subscriptionService.getUserSubscriptionStatus(userId); + // Monthly flashcard caps (per saved/generated content) + const monthlyCaps: Record = { + free: 10, + basic: 500, + pro: 2000, + orgs: 10000, + }; + const planKey = status.plan?.toLowerCase?.() || 'free'; + const monthlyCap = monthlyCaps[planKey] ?? 10; + + // Calculate current month's usage based on saved flashcards + const now = new Date(); + const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0)); + + const usedThisMonth = await prisma.flashcard.count({ + where: { + userId, + createdAt: { gte: startOfMonth }, + }, + }); + + const remainingThisMonth = Math.max(0, monthlyCap - usedThisMonth); + if (remainingThisMonth <= 0) { + const error: ApiError = { + error: "Monthly limit reached", + details: `You've reached your monthly limit of ${monthlyCap} flashcards for the ${planKey} plan.` + }; + return NextResponse.json(error, { status: 403 }); + } + + // Always generate at most 10 at a time, and never exceed remaining monthly allowance + const PER_REQUEST_CAP = 10; + const desiredCount = Math.min(PER_REQUEST_CAP, remainingThisMonth); + + // ✅ CORRECT: Single Responsibility - Delegate to service with count const flashcards = await flashcardService.generateFlashcards(text); const response: FlashcardGenerationResponse = { flashcards }; diff --git a/src/app/api/razorpay/webhook/route.ts b/src/app/api/razorpay/webhook/route.ts new file mode 100644 index 0000000..fc27a58 --- /dev/null +++ b/src/app/api/razorpay/webhook/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { prisma } from '@/lib/database'; + +// Ensure this route is always handled at runtime and not during build +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Webhook secret from Razorpay dashboard +// Note: Do NOT throw at module init; check inside the handler to avoid build-time failures + +export async function POST(req: NextRequest) { + console.log('🔔 Razorpay webhook received'); + + try { + const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET; + if (!WEBHOOK_SECRET) { + console.error('❌ Missing RAZORPAY_WEBHOOK_SECRET'); + return NextResponse.json({ error: 'Missing webhook secret' }, { status: 500 }); + } + + const body = await req.text(); + const signature = req.headers.get('x-razorpay-signature'); + + if (!signature) { + console.log('❌ No signature found in webhook'); + return NextResponse.json({ error: 'No signature' }, { status: 400 }); + } + + // Verify webhook signature (constant-time) + const expectedSignature = crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(body) + .digest('hex'); + + const providedBuf = Buffer.from(signature, 'hex'); + const expectedBuf = Buffer.from(expectedSignature, 'hex'); + + if (providedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(providedBuf, expectedBuf)) { + console.log('❌ Invalid webhook signature'); + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); + } + + console.log('✅ Webhook signature verified'); + + const event = JSON.parse(body); + console.log('đŸ“Ļ Webhook event:', event.event); + + // Handle different payment events + switch (event.event) { + case 'payment.captured': + await handlePaymentCaptured(event.payload.payment.entity); + break; + + case 'payment.failed': + await handlePaymentFailed(event.payload.payment.entity); + break; + + case 'order.paid': + await handleOrderPaid(event.payload.order.entity); + break; + + default: + console.log('â„šī¸ Unhandled webhook event:', event.event); + } + + return NextResponse.json({ success: true }); + + } catch (error) { + console.error('đŸ’Ĩ Webhook error:', error); + return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 }); + } +} + +async function handlePaymentCaptured(payment: any) { + console.log('💰 Payment captured:', payment.id); + + try { + // Find the payment record + const paymentRecord = await prisma.payment.findFirst({ + where: { razorpayPaymentId: payment.id } + }); + + if (!paymentRecord) { + console.log('❌ Payment record not found for:', payment.id); + return; + } + + // Update payment status + if (paymentRecord.status === 'COMPLETED') { + console.log('âš ī¸ Payment already processed:', payment.id); + return; + } + await prisma.payment.update({ + where: { id: paymentRecord.id }, + data: { + status: 'COMPLETED', + razorpayPaymentId: payment.id, + updatedAt: new Date() + } + }); + + // Get order details to determine subscription + const order = await prisma.payment.findFirst({ + where: { razorpayOrderId: payment.order_id } + }); + + if (order) { + // Calculate subscription dates + const now = new Date(); + const subscriptionStartedAt = new Date(now); + const subscriptionEndsAt = new Date(now); + const daysToAdd = order.billingCycle === 'monthly' ? 30 : 365; + subscriptionEndsAt.setDate(subscriptionEndsAt.getDate() + daysToAdd); + + // Update user subscription + await prisma.user.update({ + where: { clerkUserId: order.userId }, + data: { + subscriptionPlan: order.plan, + subscriptionCycle: order.billingCycle, + subscriptionStatus: 'active', + paymentId: payment.id, + subscriptionEndsAt: subscriptionEndsAt, + subscriptionStartedAt: subscriptionStartedAt, + updatedAt: new Date() + } + }); + + console.log('✅ User subscription activated:', { + userId: order.userId, + plan: order.plan, + billingCycle: order.billingCycle + }); + } + + } catch (error) { + console.error('❌ Error handling payment captured:', error); + } +} + +async function handlePaymentFailed(payment: any) { + console.log('❌ Payment failed:', payment.id); + + try { + // Find and update payment record (fallback by order id) + const paymentRecord = await prisma.payment.findFirst({ + where: { + OR: [ + { razorpayPaymentId: payment.id }, + { razorpayOrderId: payment.order_id } + ] + } + }); + + if (paymentRecord) { + await prisma.payment.updateMany({ + where: { id: paymentRecord.id, status: { not: 'COMPLETED' } }, + data: { + status: 'FAILED', + failureReason: payment.error_description || 'Payment failed', + updatedAt: new Date() + } + }); + + console.log('✅ Payment marked as failed:', payment.id); + } + + } catch (error) { + console.error('❌ Error handling payment failed:', error); + } +} + +async function handleOrderPaid(order: any) { + console.log('✅ Order paid:', order.id); + + try { + // Update order status + await prisma.payment.updateMany({ + where: { razorpayOrderId: order.id }, + data: { + status: 'COMPLETED', + updatedAt: new Date() + } + }); + + console.log('✅ Order marked as paid:', order.id); + + } catch (error) { + console.error('❌ Error handling order paid:', error); + } +} diff --git a/src/app/api/user/subscription-status/route.ts b/src/app/api/user/subscription-status/route.ts new file mode 100644 index 0000000..be57e75 --- /dev/null +++ b/src/app/api/user/subscription-status/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; +import { subscriptionService } from '@/lib/services/subscription.service'; + +export async function GET(req: NextRequest) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const subscriptionStatus = await subscriptionService.getUserSubscriptionStatus(userId); + + return NextResponse.json(subscriptionStatus); + + } catch (error) { + console.error('❌ Error fetching subscription status:', error); + return NextResponse.json( + { error: 'Failed to fetch subscription status' }, + { status: 500 } + ); + } +} diff --git a/src/app/flashcards/page.tsx b/src/app/flashcards/page.tsx index 0cd29c0..adc66e9 100644 --- a/src/app/flashcards/page.tsx +++ b/src/app/flashcards/page.tsx @@ -8,12 +8,14 @@ import { Loader2, Plus } from "lucide-react"; import DeckList from "@/components/decks/DeckList"; import type { Deck } from "@/types"; import GlobalSearch from '@/components/search/GlobalSearch' +import { useSubscription } from "@/hooks/useSubscription"; // Single Responsibility: Manage flashcards overview page export default function FlashcardsPage() { const [decks, setDecks] = useState([]); const [loading, setLoading] = useState(true); const { user, isLoaded } = useUser(); + const { canAccessPro } = useSubscription(); useEffect(() => { const fetchDecks = async () => { @@ -66,7 +68,7 @@ export default function FlashcardsPage() {

Your Flashcards

- + diff --git a/src/app/page.tsx b/src/app/page.tsx index a4cefec..417fd08 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,6 +9,8 @@ import FAQ from "@/components/FAQ"; import Noise from "@/components/noise"; import { ToastContainer } from "react-toastify"; import { motion } from "framer-motion"; +import { useUser } from "@clerk/nextjs"; +import { useSubscription } from "@/hooks/useSubscription"; const avatars = [ @@ -18,6 +20,9 @@ const avatars = [ ]; export default function Home() { + const { isSignedIn } = useUser(); + const { canAccessPro } = useSubscription(); + return (
{/* Toast Notification Container */} @@ -102,11 +107,11 @@ export default function Home() { className={`group relative overflow-hidden bg-gradient-to-r from-purple-700 to-purple-600 hover:from-purple-600 hover:to-purple-500 text-white dark:from-purple-600 dark:to-purple-500 dark:hover:from-purple-500 dark:hover:to-purple-400 px-8 py-4 rounded-xl font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:scale-105 transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2 ${buttonVariants({ size: "lg", })}`} - href="/generate" + href={isSignedIn ? (canAccessPro ? "/generate-pro" : "/generate") : "/sign-up"} aria-label="Get started with FlashFathom AI" > - Get started + {isSignedIn ? (canAccessPro ? "Go to Pro Generator" : "Generate Flashcards") : "Get started"} }> + + + ); +}; + export default Page; diff --git a/src/app/result/page.tsx b/src/app/result/page.tsx index 7fc87c4..e85873b 100644 --- a/src/app/result/page.tsx +++ b/src/app/result/page.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -9,44 +9,26 @@ import { Loader2, CreditCard, CheckCircle, XCircle } from 'lucide-react' import { Skeleton } from "@/components/ui/skeleton" import Link from 'next/link' -interface Session { - payment_status: string; - // Add other session properties as needed -} +// Stripe is removed; keep only simple status via query param // ✅ FIXED: Separate component for search params logic function ResultContent() { - const router = useRouter() const searchParams = useSearchParams() - const session_id = searchParams.get('session_id') + const payment = searchParams.get('payment') // success | failed const [loading, setLoading] = useState(true) - const [session, setSession] = useState(null) const [error, setError] = useState(null) useEffect(() => { - const fetchCheckoutSession = async () => { - if (!session_id) { - setError('No session ID provided') - setLoading(false) - return - } - - try { - const res = await fetch(`/api/checkout-sessions?session_id=${session_id}`) - const sessionData = await res.json() - if (res.ok) { - setSession(sessionData) - } else { - setError(sessionData.error) - } - } catch (err) { - setError('An error occurred while retrieving the session.') - } finally { - setLoading(false) - } - } - fetchCheckoutSession() - }, [session_id]) + const resolveResult = () => { + if (payment === 'success' || payment === 'failed') { + setLoading(false) + return + } + setError('Missing payment status') + setLoading(false) + } + resolveResult() + }, [payment]) if (loading) { return ( @@ -86,7 +68,7 @@ function ResultContent() {
- ) : session?.payment_status === 'paid' ? ( + ) : payment === 'success' ? (
@@ -94,9 +76,6 @@ function ResultContent() { Thank you for your purchase! 🎉
-

- Session ID: {session_id} -

We have received your payment successfully. You will receive an email with the order details shortly.

@@ -122,9 +101,7 @@ function ResultContent() {

Payment Failed

-

- Your payment was not successful. Please check your payment method and try again. -

+

Your payment failed. Please try again.

diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 0dc1fee..3e11814 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -4,9 +4,13 @@ import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Github, Linkedin, Twitter, Heart, ExternalLink, Zap } from "lucide-react"; import Image from "next/image"; +import { useUser } from "@clerk/nextjs"; +import { useSubscription } from "@/hooks/useSubscription"; export default function Footer() { const currentYear = new Date().getFullYear(); + const { isSignedIn } = useUser(); + const { canAccessPro } = useSubscription(); return (