diff --git a/.env.example b/.env.example index 3ed4dda..b1cde8c 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,9 @@ LINGO_API_KEY=your_lingo_api_key ELEVENLABS_API_KEY=your_elevenlabs_api_key RESEND_API_KEY=your_resend_api_key YOUTUBE_API_KEY=your_youtube_api_key -NEXT_PUBLIC_BASE_URL=your_base_url - -# Google OAuth Credentials -GOOGLE_CLIENT_SECRET=your_google_client_secret -GOOGLE_CLIENT_ID=your_google_client_id \ No newline at end of file +NEXT_PUBLIC_BASE_URL=http://localhost:3000/ +GOOGLE_CLIENT_SECRET=your_google_aouth_client_secret +GOOGLE_CLIENT_ID=your_google_aouth_client_id +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key +STRIPE_SECRET_KEY=your_stripe_secret_key +STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret diff --git a/README.md b/README.md index d23f073..9b4da23 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,20 @@ ``` - Note: This uses the schema from `packages/supabase/migrations/` and won't add any seed data. -4. **Set up environment variables** +4. **Set up Stripe Integration (Local Setup)** +- Get pk_test_***** and sk_test_**** from your stripe dashboard [https://dashboard.stripe.com/acct_*****/test/apikeys](https://dashboard.stripe.com/) +- In another terminal, run Stripe CLI to forward events to your local webhook endpoint + ```bash + stripe listen --forward-to localhost:3000/api/stripe/webhook +- A webhook secret will be generated in your terminal, formatted like this: whsec_****** + - Edit `apps/web/.env` and `apps/api/.env` to include your stripe credentials: + ``` + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=whsec_****** + STRIPE_SECRET_KEY=pk_test_****** + STRIPE_WEBHOOK_SECRET=sk_test_****** + ``` + +5. **Set up environment variables** ```bash # Copy example environment files cp apps/web/.env.example apps/web/.env @@ -70,12 +83,12 @@ ``` (Get these from your Supabase dashboard under Settings > API.) -5. **Start development servers** +6. **Start development servers** ```bash pnpm run dev ``` -6. **Open your browser** +7. **Open your browser** - Frontend: [http://localhost:3000](http://localhost:3000) - Backend: [http://localhost:8000](http://localhost:8000) diff --git a/apps/api/env.example b/apps/api/env.example index 93e788e..2106d05 100644 --- a/apps/api/env.example +++ b/apps/api/env.example @@ -14,11 +14,13 @@ SUPABASE_SERVICE_KEY=your_supabase_service_role_key # Google AI Studio API Key for script generation GOOGLE_AI_STUDIO_API_KEY=your_google_ai_studio_api_key + # Optional: Additional AI Services # OPENAI_API_KEY=your_openai_api_key # ELEVENLABS_API_KEY=your_elevenlabs_api_key # Optional: Payment Processing +# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key # STRIPE_SECRET_KEY=your_stripe_secret_key # STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret diff --git a/apps/web/.env.example b/apps/web/.env.example index d466b3d..86265ee 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,4 +16,11 @@ NEXT_PUBLIC_BASE_URL=your_base_url # Google OAuth Credentials GOOGLE_CLIENT_SECRET=your_google_client_secret -GOOGLE_CLIENT_ID=your_google_client_id \ No newline at end of file +GOOGLE_CLIENT_ID=your_google_client_id + +# Stripe Api Keys +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_**** +STRIPE_SECRET_KEY=sk_test_**** +STRIPE_WEBHOOK_SECRET=whsec_***** +NEXT_PUBLIC_PRO_PRICE = price_***** +NEXT_PUBLIC__ENTERPRICE_PLAN = price_***** diff --git a/apps/web/app/api/stripe/cancel-subscription/route.ts b/apps/web/app/api/stripe/cancel-subscription/route.ts new file mode 100644 index 0000000..671fba2 --- /dev/null +++ b/apps/web/app/api/stripe/cancel-subscription/route.ts @@ -0,0 +1,26 @@ +import { createClient } from "@/lib/supabase/server"; +import { stripe } from "@/lib/stripe"; +import { NextResponse } from "next/server"; + + +export async function POST(req: Request) { + const supabase = await createClient(); + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + try { + const { data: existing } = await supabase + .from("subscriptions") + .select("stripe_subscription_id") + .eq("user_id", user?.id) + .single() + + const subscriptionId = existing?.stripe_subscription_id + const updatedSubscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + return NextResponse.json(updatedSubscription); + } catch (error:any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/stripe/create-subscription/route.ts b/apps/web/app/api/stripe/create-subscription/route.ts new file mode 100644 index 0000000..f293638 --- /dev/null +++ b/apps/web/app/api/stripe/create-subscription/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { stripe } from "@/lib/stripe"; +import { createClient } from "@/lib/supabase/server"; + + +export async function POST(req: Request) { + const supabase = await createClient(); + const pro__plan = process.env.NEXT_PUBLIC_PRO_PRICE + const enterprice_plan = process.env.NEXT_PUBLIC__ENTERPRICE_PLAN + try { + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { sub_type, plan_id, stripe_customer_id } = await req.json(); + + const { data: existing } = await supabase + .from("subscriptions") + .select("stripe_subscription_id, plan_id, stripe_customer_id") + .eq("user_id", user.id) + .single() + const { data: profile } = await supabase + .from("profiles") + .select("full_name") + .eq("user_id", user.id) + .single() + + // if(existing?.plan_id === plan_id){ + // return NextResponse.json({ error: `${sub_type} is your active plan, Please select a different active. plan if you want to upgrade` }, { status: 400 }); + // } + + let customerId; + + if(stripe_customer_id) { + customerId = stripe_customer_id + }else { + const customer = await stripe.customers.create({ + name: profile?.full_name, + metadata: { user_id: user.id }, + email: user.email + }) + customerId = customer.id + } + + + + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + customer: customerId, + line_items: [ + { + price: sub_type === "Pro" ? pro__plan : enterprice_plan, + quantity: 1, + }, + ], + success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cancel`, + metadata: { + user_id: user.id, + sub_type: sub_type, + plan_id: plan_id + }, + subscription_data: { + metadata: { + user_id: user.id, + sub_type, + plan_id, + } + } + }); + + + + + return NextResponse.json({ + url: session.url + + }); + } catch (error: any) { + console.error("Stripe Error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/apps/web/app/api/stripe/route.ts b/apps/web/app/api/stripe/route.ts new file mode 100644 index 0000000..d823fa5 --- /dev/null +++ b/apps/web/app/api/stripe/route.ts @@ -0,0 +1,38 @@ +import { stripe } from "@/lib/stripe"; +import { createClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + const supabase = await createClient(); + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { data: existing } = await supabase + .from("subscriptions") + .select("stripe_customer_id") + .eq("user_id", user.id) + .single() + + const charges = await stripe.charges.list({ + customer: existing?.stripe_customer_id, // Customer ID + limit: 100, + }); + + // Or using PaymentIntents (recommended for newer integrations) + const paymentIntents = await stripe.paymentIntents.list({ + customer: existing?.stripe_customer_id, + limit: 100, + }); + return NextResponse.json({ + charges, + paymentIntents + + }); + } catch (error:any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/stripe/update-subscription/route.ts b/apps/web/app/api/stripe/update-subscription/route.ts new file mode 100644 index 0000000..066486e --- /dev/null +++ b/apps/web/app/api/stripe/update-subscription/route.ts @@ -0,0 +1,34 @@ +import { createClient } from "@/lib/supabase/server"; +import { stripe } from "@/lib/stripe"; +import { NextResponse } from "next/server"; + + +export async function POST(req: Request) { + const supabase = await createClient(); + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + const { price_id, sub_type } = await req.json(); + + const { data: existing } = await supabase + .from("subscriptions") + .select("stripe_subscription_id") + .eq("user_id", user?.id) + .single() + + const subscriptionId = existing?.stripe_subscription_id + + const subscription: any = await stripe.subscriptions.retrieve(existing?.stripe_subscription_id); + const subscriptionItemId = subscription.items.data[0].id; + + const updatedSubscription = await stripe.subscriptions.update(subscriptionId, { + items: [ + { + id: subscriptionItemId, + price: price_id, + }, + ], + proration_behavior: "none", + }); + + return NextResponse.json({ message: "Success!, New plan will start at the end of the month", updatedSubscription, status: 200 }); +} \ No newline at end of file diff --git a/apps/web/app/api/stripe/webhook/route.ts b/apps/web/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..ca4b8a6 --- /dev/null +++ b/apps/web/app/api/stripe/webhook/route.ts @@ -0,0 +1,273 @@ +import { NextResponse } from "next/server"; +import { stripe } from "@/lib/stripe"; +import { headers } from "next/headers"; +import { supabaseAdmin } from "@/lib/supabase/webhook"; +import { SubType } from "@/types/subscription"; + + + +export async function POST(req: Request) { + const body = await req.text(); + const headersList = await headers(); + const sig = headersList.get("stripe-signature"); + + let event; + + try { + event = stripe.webhooks.constructEvent( + body, + sig!, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (err: any) { + console.error("❌ Webhook signature verification failed:", err.message); + return NextResponse.json({ error: err.message }, { status: 400 }); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as any; + const subscriptionId = session.subscription; + const customerId = session.customer; + let userId = session.metadata?.user_id; + let sub_type: SubType = session.metadata?.sub_type; + let plan_id: SubType = session.metadata?.plan_id; + + // if (!userId) { + // try { + // const customer = await stripe.customers.retrieve(customerId); + // userId = (customer as any).metadata?.user_id ?? (customer as any).metadata?.userId; + // } catch (err) { + // console.error("Failed to retrieve Stripe customer:", err); + // } + // } + + // if (!userId) { + // console.warn("No userId found for subscription.created event, skipping credit increment."); + // break; + // } + + // console.log("subscription created", userId); + + // Read current credits and update + // try { + // const { data: userRow, error: fetchError } = await supabaseAdmin + // .from("profiles") + // .select("credits") + // .eq("user_id", userId) + // .single(); + + // if (fetchError) { + // console.error("Failed to fetch user for credit update:", fetchError); + // break; + // } + // const amount_to_add = sub_type === "Pro" ? 5000 : 100000; + // const currentCredits = (userRow as any)?.credits ?? 0; + // const newCredits = currentCredits + amount_to_add + + // const { error: updateError } = await supabaseAdmin + // .from("profiles") + // .update({ credits: newCredits }) + // .eq("user_id", userId); + + // if (updateError) { + // console.error("Failed to update user credits:", updateError); + // break; + // } + + // const { data: existing, error } = await supabaseAdmin + // .from("subscriptions") + // .select("stripe_customer_id, stripe_subscription_id") + // .eq("user_id", userId) + // .single() + + + // const subscription_data: any = await stripe.subscriptions.retrieve(subscriptionId); + // const subscriptionItemEndDate = subscription_data.items.data[0].current_period_end; + // const subscriptionItemStartDate = subscription_data.items.data[0].current_period_start; + // const starts = new Date(subscriptionItemStartDate * 1000) + // const expires = new Date(subscriptionItemEndDate * 1000) + + // const { error: subError } = await supabaseAdmin.from("subscriptions").upsert({ + // user_id: userId, + // plan_id: plan_id, + // stripe_subscription_id: session.subscription, + // current_period_end: expires.toUTCString(), + // current_period_start: starts.toUTCString(), + // status: "active", + // updated_at: new Date().toISOString(), + // stripe_customer_id: session.customer + // }) + + // if (subError) { + // console.log(error, "errorrr") + // return NextResponse.json({ message: 'Something happened', error }, { status: 401 }); + // } + + + // } catch (err) { + // console.error("Error updating credits:", err); + // } + break; + } + + case "customer.subscription.created": { + const sub = event.data.object as any; + + // console.log(sub) + break; + } + case "invoice.payment_succeeded": { + let userId; + const invoice = event.data.object as any; + const customerId = invoice.customer; + + const subscription = await stripe.subscriptions.retrieve( + invoice.parent.subscription_details.subscription + ); + + console.log(customerId, "customerId") + + const metadata = subscription.metadata + userId = metadata?.user_id + const plan_id = metadata?.plan_id + + if (!userId) { + try { + const customer = await stripe.customers.retrieve(customerId); + userId = (customer as any).metadata?.user_id ?? (customer as any).metadata?.userId; + } catch (err) { + console.error("Failed to retrieve Stripe customer:", err); + } + } + + + if (!userId) { + console.warn("No userId found for subscription.created event, skipping credit increment."); + break; + } + + try { + + + const { data: userRow, error: fetchError } = await supabaseAdmin + .from("profiles") + .select("credits") + .eq("user_id", userId) + .single(); + + if (fetchError) { + console.error("Failed to fetch user for credit update:", fetchError); + break; + } + const amount_to_add = 5000; + const currentCredits = (userRow as any)?.credits ?? 0; + const newCredits = currentCredits + amount_to_add + + const { error: updateError } = await supabaseAdmin + .from("profiles") + .update({ credits: newCredits }) + .eq("user_id", userId); + + if (updateError) { + console.error("Failed to update user credits:", updateError); + break; + } + + const { data: existing, error } = await supabaseAdmin + .from("subscriptions") + .select("stripe_customer_id, stripe_subscription_id") + .eq("user_id", userId) + .single() + + + const subscription_data: any = await stripe.subscriptions.retrieve(subscription.id); + const subscriptionItemEndDate = subscription_data.items.data[0].current_period_end; + const subscriptionItemStartDate = subscription_data.items.data[0].current_period_start; + const starts = new Date(subscriptionItemStartDate * 1000) + const expires = new Date(subscriptionItemEndDate * 1000) + let sub_error; + + if(existing) { + const { error: subError } = await supabaseAdmin.from("subscriptions").update({ + user_id: userId, + plan_id: plan_id, + stripe_subscription_id: subscription.id, + current_period_end: expires.toUTCString(), + current_period_start: starts.toUTCString(), + status: "active", + updated_at: new Date().toISOString(), + stripe_customer_id: customerId + }).eq("stripe_customer_id",customerId) + sub_error = subError + } else { + const { error: subError } = await supabaseAdmin.from("subscriptions").upsert({ + user_id: userId, + plan_id: plan_id, + stripe_subscription_id: subscription.id, + current_period_end: expires.toUTCString(), + current_period_start: starts.toUTCString(), + status: "active", + updated_at: new Date().toISOString(), + stripe_customer_id: customerId + }) + sub_error = subError + } + + if (sub_error) { + console.log(error, "errorrr") + return NextResponse.json({ message: 'Something happened', error }, { status: 401 }); + } + + + } catch (err) { + console.error("Error updating credits:", err); + } + + + + // await db.user.update({ + // where: { id: userId }, + // data: { status: "active" }, + // }); + break; + } + + case "invoice.payment_failed": { + const invoice = event.data.object as any; + const customerId = invoice.customer; + const customer = await stripe.customers.retrieve(customerId); + const userId = (customer as any).metadata.userId; + // console.log(invoice, "invoice") + + // await db.user.update({ + // where: { id: userId }, + // data: { status: "past_due" }, + // }); + break; + } + + case "customer.subscription.deleted": { + const subscription = event.data.object as any; + const customerId = subscription.customer; + const customer = await stripe.customers.retrieve(customerId); + const userId = (customer as any).metadata.user_id; + + const { error } = await supabaseAdmin.from("subscriptions").update({ + status: "canceled", + plan_id: "51cb98f9-192a-4464-9a15-e3114aaf0c20" + }).eq("user_id", userId); + break; + } + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + return NextResponse.json({ received: true }); + } catch (err: any) { + console.error("Webhook handler failed:", err); + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/apps/web/app/dashboard/plan/page.tsx b/apps/web/app/dashboard/plan/page.tsx new file mode 100644 index 0000000..c433694 --- /dev/null +++ b/apps/web/app/dashboard/plan/page.tsx @@ -0,0 +1,10 @@ +import PricingSection from '@/components/landingPage/PricingSection' +import React from 'react' + +const page = () => { + return ( + + ) +} + +export default page \ No newline at end of file diff --git a/apps/web/components/dashboard/main/ReturningUserHub.tsx b/apps/web/components/dashboard/main/ReturningUserHub.tsx index 93c24d3..585b827 100644 --- a/apps/web/components/dashboard/main/ReturningUserHub.tsx +++ b/apps/web/components/dashboard/main/ReturningUserHub.tsx @@ -194,7 +194,7 @@ export function ReturningUserHub({
Free Plan
- + diff --git a/apps/web/components/dashboard/settings/BillingInfo.tsx b/apps/web/components/dashboard/settings/BillingInfo.tsx index 5d51f0f..8f761c8 100644 --- a/apps/web/components/dashboard/settings/BillingInfo.tsx +++ b/apps/web/components/dashboard/settings/BillingInfo.tsx @@ -5,7 +5,12 @@ import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useSettings } from "@/hooks/useSettings"; import { CreditCard, Loader2 } from "lucide-react" -import { useState, useEffect } from "react" +import { useState, useEffect, useMemo } from "react" +import { useRouter } from "next/navigation" +import { createClient } from "@/lib/supabase/client"; +import { useSupabase } from "@/components/supabase-provider"; +import {capitalize} from "@/helpers/capitalize" +import { formatDate } from "@/helpers/formatDate"; // A type for the component's data state interface BillingData { @@ -14,13 +19,48 @@ interface BillingData { paymentMethod: string | null } +interface SubscriptionDetails { + subscription_end_date: string; + subscription_type: string; +} + export function BillingInfo() { - const { updateBilling, loadingBilling } = useSettings() + const { updateBilling, loadingBilling, subscriptionDetails, fetchSubscriptionDetails } = useSettings() + const router = useRouter() + const { supabase, user, fetchPlan, plans, planLoading } = useSupabase() const [isLoadingData, setIsLoadingData] = useState(true) const [billingData, setBillingData] = useState(null) + + useEffect(() => { + if(user) { + fetchSubscriptionDetails(user?.id) + fetchPlan() + } + }, [user]) + + const findUserPlan = plans?.find(item => item.id === subscriptionDetails?.plan_id) + + const fetchTransactionHistory = async() => { + const res = await fetch("/api/stripe", { + method: "GET" + }) + + if(!res.ok) throw new Error("Failed to fetch transaction histpry") + + const data = await res.json() + console.log(data) + return + } + + useEffect(() => { + if(subscriptionDetails) { + fetchTransactionHistory() + } + }, [subscriptionDetails]) + useEffect(() => { const fetchBillingData = async () => { @@ -67,10 +107,10 @@ export function BillingInfo() { ) : ( <> -

{billingData?.currentPlan} Plan

- {billingData?.nextBillingDate && ( +

{capitalize(findUserPlan?.name) || "Free"} Plan

+ {subscriptionDetails?.current_period_end && (

- Next billing date: {billingData.nextBillingDate} + Next billing date: {formatDate(subscriptionDetails.current_period_end) || "Please subscribe to a plan"}

)} @@ -79,7 +119,7 @@ export function BillingInfo() { {isLoadingData ? ( ) : ( - + )} diff --git a/apps/web/components/dashboard/settings/UpgradePlan.tsx b/apps/web/components/dashboard/settings/UpgradePlan.tsx new file mode 100644 index 0000000..a66f1c3 --- /dev/null +++ b/apps/web/components/dashboard/settings/UpgradePlan.tsx @@ -0,0 +1,56 @@ +// components/Modal.tsx +"use client" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +// import * as Dialog from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +export default function UpgradePlan() { + return ( + + + + + + + {/*OverlayDialogOverlay */} + + + {/* Content */} + +
+ Select Plan + {/* + + */} +
+ + + This is an example modal built using Radix UI Dialog. + + +
+ + + + +
+
+
+
+ ) +} diff --git a/apps/web/components/landingPage/PricingSection.tsx b/apps/web/components/landingPage/PricingSection.tsx index 44ea301..4a4ad6c 100644 --- a/apps/web/components/landingPage/PricingSection.tsx +++ b/apps/web/components/landingPage/PricingSection.tsx @@ -1,120 +1,156 @@ +"use client" import { cn } from "@/lib/utils" import Link from "next/link" import { Button } from "../ui/button" import { WobbleCard } from "../ui/wobble-card" +import { usePathname, useRouter } from "next/navigation" +import { toast } from "sonner" +import { useEffect, useState } from "react" +import { useSupabase } from "../supabase-provider" +import { Skeleton } from "../ui/skeleton" +import { Features, Plans } from "@/types/plans" +import { capitalize } from "@/helpers/capitalize" +import { useSettings } from "@/hooks/useSettings" export default function PricingSection() { - const baseFeatures = { - starter: [ - "Connect YouTube channel", - "AI model training", - "New idea research", - "Script generation", - "Thumbnail generation", - "Subtitle generation", - "Course module creation", - ], - proExtras: [ - "Everything in Starter", - "Unlimited feature usage", - "Audio dubbing", - ], - enterpriseExtras: [ - "Everything in Pro", - "Advanced analytics", - "Team collaboration", - "Custom fine-tuned model", - "Priority support", - ], + const { loadingBilling, subscriptionDetails, fetchSubscriptionDetails } = useSettings() + const [loading, setLoading] = useState(false) + const { user, supabase, fetchPlan, plans, planLoading } = useSupabase(); + + const pathname = usePathname() + const router = useRouter() + + const pro__plan = process.env.NEXT_PUBLIC_PRO_PRICE + const enterprice_plan = process.env.NEXT_PUBLIC__ENTERPRICE_PLAN + + + useEffect(() => { + if(user) { + fetchPlan() + fetchSubscriptionDetails(user?.id) + } + }, []) + + const handleStripeCheckout = async (plan_id: string, sub_type: string, ) => { + if (loading) return + setLoading(true) + try { + const response = await fetch('/api/stripe/create-subscription', { + method: "POST", + body: JSON.stringify({ plan_id, sub_type, stripe_customer_id: subscriptionDetails?.stripe_customer_id }) + }) + + const data = await response.json() + if (!response.ok) { + toast.error(data.error || "Something went wrong") + return + } + if (data.url) window.location.href = data.url; + } catch (error: any) { + toast.error("Failed to send issue report", { + description: error?.message || "Something went wrong. Please try again.", + }); + } finally { + setLoading(false) + } } + console.log(subscriptionDetails, "subscriptionDetails") - const newPlans = [ + const handlePricingRoute = (plan_id: string, sub_type: string) => { + if (user) { + sub_type === "Starter" ? router.push("/dashboard") : sub_type === "Pro" ? handleStripeCheckout( plan_id,sub_type): router.push("/sales") + } else { + router.push("/login") + } + } + const pricing = [ { - id: "starter", - name: "Starter", - price_monthly: 0, - credits_monthly: 500, - features: baseFeatures.starter.map((f) => ({ feature: f, limit: "limited" })), + price: 0, + heading: "Free", + confess: "Get Started", + description: "Perfect for trying out Script AI.", + features: ["10 free credits per month", "Personalized AI training", "Tone selection"] }, { - id: "pro", - name: "Pro", - price_monthly: 20, - credits_monthly: 5000, - features: [ - ...baseFeatures.proExtras, - ].map((f) => ({ feature: f, limit: "unlimited" })), + price: 19, + heading: "Pro", + isPopular: true, + confess: "Get Started", + description: "For serious content creators.", + features: ["300 credits", "Custom fine tuned model", "All script customization options", "Thumbnail & title generation", "Course module creaton"], + stripe_pricing: pro__plan, + credit_to_be_added: 300 }, { - id: "enterprise", - name: "Enterprise", - price_monthly: 499, - credits_monthly: 100000, - features: [ - ...baseFeatures.enterpriseExtras, - ].map((f) => ({ feature: f, limit: "unlimited" })), + price: 49, + heading: "Enterprise", + confess: "Contact Sales", + description: "For professional YouTubers and teams.", + features: ["Everything in Pro", "Team collaboration", "Advanced analytics", "Priority support", "Custom integrations"], + stripe_pricing: enterprice_plan }, ] - return (
-

- Pricing -

+ { + pathname === "/" ?

+ Pricing +

:

+ Recommended plan for you +

+ }

Choose the plan that works best for your content creation needs.

- {newPlans.map((plan) => { - const isPopular = plan.name === "Pro" - const buttonLabel = - plan.name === "Enterprise" ? "Contact Sales" : "Get Started" - - return ( - - {isPopular && ( -
- POPULAR -
- )} - -

- {plan.name} + {plans && plans.map((option:Plans, key) => ( + + {option?.name === "Pro" && ( +
+ POPULAR +
+ )} + { + planLoading || loadingBilling ? :

+ {option.name}

- -
- ${plan.price_monthly} + } + { + planLoading || loadingBilling ? :
+ ${option.price_monthly} /mo
- -

- {plan.name === "Enterprise" + } + { + planLoading || loadingBilling ? :

+ {option.name === "Enterprise" ? "For professional YouTubers and teams." - : plan.name === "Pro" + : option.name === "Pro" ? "For serious content creators." : "Perfect for trying out Script AI."}

- -
    - {plan.features.map((f, i) => ( + } + { + planLoading || loadingBilling ? <> + + :
      + {option.features.map((f: Features, featureKey) => (
    • - {f.feature} - {f.limit !== "unlimited" && ( + {f.feature} {f.limit !== "unlimited" && ( ({f.limit}) @@ -139,24 +174,27 @@ export default function PricingSection() {
    • ))}
    + } - - - ) - })} + } + + + ) + )}

) diff --git a/apps/web/components/supabase-provider.tsx b/apps/web/components/supabase-provider.tsx index e46adc4..f232bbe 100644 --- a/apps/web/components/supabase-provider.tsx +++ b/apps/web/components/supabase-provider.tsx @@ -13,6 +13,7 @@ import { import { type SupabaseClient, type User, type Session } from "@supabase/supabase-js" import { createClient } from "@/lib/supabase/client" import { UserProfile } from "@repo/validation" +import { Plans } from "@/types/plans" type SupabaseContext = { supabase: SupabaseClient @@ -26,6 +27,9 @@ type SupabaseContext = { setProfile: Dispatch> profileLoading: boolean fetchUserProfile: (userId: string) => Promise + fetchPlan: () => Promise + plans: Plans[] | null + planLoading: boolean } // Suspense boundary helpers @@ -45,6 +49,9 @@ export function SupabaseProvider({ children }: { children: React.ReactNode }) { const [profile, setProfile] = useState(null) const [profileLoading, setProfileLoading] = useState(true) + const [planLoading, setplanLoading] = useState(false) + const [plans, setPlans] = useState(null) + // Generate referral code function generateReferralCode(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -100,6 +107,28 @@ export function SupabaseProvider({ children }: { children: React.ReactNode }) { } } + const fetchPlan = async () => { + setplanLoading(true) + try { + const { data, error } = await supabase + .from('plans') + .select('*') + .eq('is_active', true) + + if (error) { + console.error('Error fetching data:', error) + return [] + } + + setPlans(data) + return data + } catch (error) { + + } finally { + setplanLoading(false) + } + } + // Initial session loader const getInitialSession = async (): Promise => { const { data, error } = await supabase.auth.getSession() @@ -148,6 +177,7 @@ export function SupabaseProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (user) { fetchUserProfile(user.id) + fetchPlan() } else { setProfile(null) setProfileLoading(false) @@ -166,6 +196,9 @@ export function SupabaseProvider({ children }: { children: React.ReactNode }) { setProfile, profileLoading, fetchUserProfile, + plans, + fetchPlan, + planLoading } return {children} diff --git a/apps/web/components/ui/wobble-card.tsx b/apps/web/components/ui/wobble-card.tsx index 2018f3d..bf81973 100644 --- a/apps/web/components/ui/wobble-card.tsx +++ b/apps/web/components/ui/wobble-card.tsx @@ -68,7 +68,7 @@ export const WobbleCard = ({ const Noise = () => { return (
{ + if (value) { + return value.charAt(0).toUpperCase() + value.slice(1); + } else { + return ""; + } +}; + diff --git a/apps/web/helpers/formatDate.ts b/apps/web/helpers/formatDate.ts new file mode 100644 index 0000000..85f11d2 --- /dev/null +++ b/apps/web/helpers/formatDate.ts @@ -0,0 +1,8 @@ +export function formatDate(isoString: string): string { + const date = new Date(isoString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) +} \ No newline at end of file diff --git a/apps/web/hooks/useSettings.ts b/apps/web/hooks/useSettings.ts index d56c732..34ec257 100644 --- a/apps/web/hooks/useSettings.ts +++ b/apps/web/hooks/useSettings.ts @@ -5,6 +5,12 @@ import { useState } from "react"; import { useSupabase } from "@/components/supabase-provider"; import { toast } from "sonner"; +interface SubscriptionDetails { + current_period_end: string; + plan_id: string; + stripe_customer_id: string; +} + export function useSettings () { const { supabase, user } = useSupabase(); @@ -14,6 +20,7 @@ export function useSettings const [isChangingPassword, setIsChangingPassword] = useState(false); const [loadingNotifications, setLoadingNotifications] = useState(false); const [loadingBilling, setLoadingBilling] = useState(false); + const [subscriptionDetails, setSubscriptionDetails] = useState(null) // --- Profile update --- const updateProfile = async ({ @@ -141,6 +148,25 @@ const [isChangingPassword, setIsChangingPassword] = useState(false); } }; + const fetchSubscriptionDetails = async (userId:string): Promise => { + setLoadingBilling(true) + try { + const { data: existing } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", userId) + .single() + + if(existing) { + setSubscriptionDetails(existing as SubscriptionDetails) + } + } catch (error) { + + } finally { + setLoadingBilling(false) + } + } + // --- Password reset --- const changePassword = async () => { if (!user?.email) return; @@ -174,5 +200,7 @@ const [isChangingPassword, setIsChangingPassword] = useState(false); // Billing updateBilling, loadingBilling, + fetchSubscriptionDetails, + subscriptionDetails, }; } diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts new file mode 100644 index 0000000..6a68cf7 --- /dev/null +++ b/apps/web/lib/stripe.ts @@ -0,0 +1,5 @@ +import Stripe from "stripe"; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-09-30.clover", +}); diff --git a/apps/web/lib/supabase/webhook.ts b/apps/web/lib/supabase/webhook.ts new file mode 100644 index 0000000..9075fc9 --- /dev/null +++ b/apps/web/lib/supabase/webhook.ts @@ -0,0 +1,6 @@ +import { createClient } from '@supabase/supabase-js'; + +export const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! +); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 7715f7e..5b68baa 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -6,6 +6,7 @@ export async function middleware(request: NextRequest) { const response = NextResponse.next() if (request.nextUrl.pathname === "/api/auth/callback" || + request.nextUrl.pathname === "/api/stripe/webhook" || request.nextUrl.pathname === "/api/track-referral" ) { return response diff --git a/apps/web/package.json b/apps/web/package.json index 8748e4f..1f782e7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -84,6 +84,7 @@ "resend": "^6.0.1", "simplex-noise": "^4.0.3", "sonner": "^1.7.1", + "stripe": "^19.1.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "three": "^0.178.0", @@ -98,6 +99,7 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "@types/stripe": "^8.0.417", "postcss": "^8.5", "tailwindcss": "^3.4.17", "typescript": "^5" diff --git a/apps/web/types/plans.ts b/apps/web/types/plans.ts new file mode 100644 index 0000000..16a42e1 --- /dev/null +++ b/apps/web/types/plans.ts @@ -0,0 +1,15 @@ + +export interface Features { + feature: string; + limit: string +} + +export interface Plans { + id: string; + name: string; + price_monthly: number; + is_active: boolean; + credits_monthly: boolean; + created_at: string; + features: Features[] +} \ No newline at end of file diff --git a/apps/web/types/subscription.ts b/apps/web/types/subscription.ts new file mode 100644 index 0000000..bab1f96 --- /dev/null +++ b/apps/web/types/subscription.ts @@ -0,0 +1,13 @@ +export type SubType = "Pro" | "Enterprise" | "Free" + +export interface Subscription { + id: string + user_id: string + plan_id: string + stripe_subscription_id: string + status: string + current_period_start: string + current_period_end: string + created_at: string + updated_at: string +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b2fb0..91a7734 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,6 +348,9 @@ importers: sonner: specifier: ^1.7.1 version: 1.7.4(react-dom@19.1.1)(react@19.1.1) + stripe: + specifier: ^19.1.0 + version: 19.1.0(@types/node@22.17.0) tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -382,6 +385,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.1.7(@types/react@19.1.9) + '@types/stripe': + specifier: ^8.0.417 + version: 8.0.417(@types/node@22.17.0) postcss: specifier: ^8.5 version: 8.5.6 @@ -4418,7 +4424,6 @@ packages: resolution: {integrity: sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==} dependencies: undici-types: 6.21.0 - dev: true /@types/phoenix@1.6.6: resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -4502,6 +4507,15 @@ packages: resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} dev: false + /@types/stripe@8.0.417(@types/node@22.17.0): + resolution: {integrity: sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw==} + deprecated: This is a stub types definition. stripe provides its own type definitions, so you do not need this installed. + dependencies: + stripe: 19.1.0(@types/node@22.17.0) + transitivePeerDependencies: + - '@types/node' + dev: true + /@types/superagent@8.1.9: resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} dependencies: @@ -9037,7 +9051,6 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.1.0 - dev: true /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -9862,6 +9875,18 @@ packages: engines: {node: '>=8'} dev: true + /stripe@19.1.0(@types/node@22.17.0): + resolution: {integrity: sha512-FjgIiE98dMMTNssfdjMvFdD4eZyEzdWAOwPYqzhPRNZeg9ggFWlPXmX1iJKD5pPIwZBaPlC3SayQQkwsPo6/YQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 22.17.0 + qs: 6.14.0 + /strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'}