diff --git a/app/(root)/create-podcast/page.tsx b/app/(root)/create-podcast/page.tsx
index be32cec..eac0840 100644
--- a/app/(root)/create-podcast/page.tsx
+++ b/app/(root)/create-podcast/page.tsx
@@ -24,16 +24,18 @@ import {
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
-import { useState } from "react"
+import { use, useState } from "react"
import { Textarea } from "@/components/ui/textarea"
import GeneratePodcast from "@/components/GeneratePodcast"
import GenerateThumbnail from "@/components/GenerateThumbnail"
-import { Loader } from "lucide-react"
+import { Loader, Lock, LockKeyhole } from "lucide-react"
import { Id } from "@/convex/_generated/dataModel"
import { useToast } from "@/components/ui/use-toast"
import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useRouter } from "next/navigation"
+import { useIsSubscribed } from "@/hooks/useIsSubscribed"
+import { useClerk } from "@clerk/nextjs"
const voiceCategories = ['alloy', 'shimmer', 'nova', 'echo', 'fable', 'onyx'];
@@ -59,6 +61,10 @@ const CreatePodcast = () => {
const createPodcast = useMutation(api.podcasts.createPodcast)
+ const { user } = useClerk();
+
+ const isSubscribed = useIsSubscribed(user?.id!);
+
const { toast } = useToast()
// 1. Define your form.
@@ -114,16 +120,25 @@ const CreatePodcast = () => {
Create Podcast
- )
+ );
}
export default CreatePodcast
\ No newline at end of file
diff --git a/app/(root)/layout.tsx b/app/(root)/layout.tsx
index 432410f..55807d9 100644
--- a/app/(root)/layout.tsx
+++ b/app/(root)/layout.tsx
@@ -2,7 +2,6 @@ import LeftSidebar from "@/components/LeftSidebar";
import MobileNav from "@/components/MobileNav";
import RightSidebar from "@/components/RightSidebar";
import Image from "next/image";
-import { Toaster } from "@/components/ui/toaster"
import PodcastPlayer from "@/components/PodcastPlayer";
export default function RootLayout({
@@ -27,8 +26,6 @@ export default function RootLayout({
-
-
{children}
diff --git a/app/(root)/profile/[profileId]/page.tsx b/app/(root)/profile/[profileId]/page.tsx
index a8ff5b3..c9e0902 100644
--- a/app/(root)/profile/[profileId]/page.tsx
+++ b/app/(root)/profile/[profileId]/page.tsx
@@ -31,6 +31,7 @@ const ProfilePage = ({
) {
return (
-
+
+
- {children}
+
+ {children}
{/* */}
-
+
+
);
diff --git a/app/plans/page.tsx b/app/plans/page.tsx
new file mode 100644
index 0000000..ce5b431
--- /dev/null
+++ b/app/plans/page.tsx
@@ -0,0 +1,471 @@
+"use client";
+
+import { useAction, useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import React, { useState } from "react";
+import Image from "next/image";
+import { useGetPlan, useIsSubscribed } from "@/hooks/useIsSubscribed";
+import { useClerk } from "@clerk/nextjs";
+import { useToast } from "@/components/ui/use-toast";
+import LeftSidebar from "@/components/LeftSidebar";
+import RightSidebar from "@/components/RightSidebar";
+import MobileNav from "@/components/MobileNav";
+import {
+ Activity,
+ Battery,
+ BatteryFull,
+ BatteryLow,
+ BatteryMedium,
+} from "lucide-react";
+import PodcastPlayer from "@/components/PodcastPlayer";
+import { useIsFetching } from "@/providers/IsFetchingProvider";
+import LoaderSpinner from "@/components/LoaderSpinner";
+import { pricingPlans } from "@/constants";
+import ButtonSpinner from "@/components/ButtonSpinner";
+import { Suspense } from "react";
+import SearchParams from "@/components/SearchParams";
+
+type planDetails = {
+ subscriptionId: string | null;
+ plan: string | null;
+ endsOn: number | null;
+};
+
+export default function Payments() {
+ const [success, setSuccess] = useState(false);
+ const router = useRouter();
+ const [annual, setAnnual] = useState(false);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [selectedPlan, setSelectedPlan] = useState("");
+ const { user } = useClerk();
+ const { toast } = useToast();
+ const { isFetching } = useIsFetching();
+
+ const isSubscribed = useIsSubscribed(user?.id ?? "");
+ const planDetails = useGetPlan(user?.id ?? "") as planDetails;
+
+ const totalPodcasts = useQuery(api.users.getTotalPodcastsOfUser, {
+ clerkId: user?.id ?? "",
+ });
+
+ const pay = useAction(api.stripe.pay);
+ const cancel = useAction(api.stripe.cancelSubscription);
+ const manage = useAction(api.stripe.createCustomerPortal);
+
+ /**
+ * TODO: Get the remaining podcasts based on the plan of the user from the database instead of hardcoding it.
+ */
+
+ const podcastsRemaining =
+ planDetails.plan === "Enterprise"
+ ? "unlimited"
+ : ((30 - totalPodcasts!) as number);
+
+ const handleUpgrade = async (plan: string) => {
+ setSelectedPlan(plan);
+ setIsLoaded(true);
+ if (plan === "Free") {
+ router.push("/");
+ return;
+ }
+
+ if (annual) {
+ plan = plan + "-annual";
+ }
+
+ try {
+ const paymentUrl = await pay({ plan });
+ router.push(paymentUrl!);
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: "An error occurred while processing your payment",
+ variant: "destructive",
+ });
+ setIsLoaded(false);
+ setSelectedPlan("");
+ }
+ setIsLoaded(false);
+ setSelectedPlan("");
+ };
+
+ const handleCancellation = async () => {
+ setSelectedPlan("cancel");
+ setIsLoaded(true);
+
+ const confirm = window.confirm(
+ "Are you sure you want to cancel your subscription?"
+ );
+
+ if (!confirm) {
+ setSelectedPlan("");
+ setIsLoaded(false);
+ return;
+ }
+
+ if (!user) {
+ toast({
+ title: "Error",
+ description: "You must be logged in to cancel your subscription",
+ variant: "destructive",
+ });
+ setSelectedPlan("");
+ setIsLoaded(false);
+ return;
+ }
+
+ if (planDetails.plan === "Free") {
+ toast({
+ title: "Error",
+ description: "You cannot cancel a free subscription",
+ variant: "destructive",
+ });
+
+ setSelectedPlan("");
+ setIsLoaded(false);
+ return;
+ }
+
+ try {
+ const cancelPlan = await cancel({ clerkId: user.id });
+
+ if (cancelPlan.success) {
+ toast({
+ title: "Success",
+ description: "Your subscription has been cancelled",
+ variant: "success",
+ });
+
+ setSelectedPlan("");
+ setIsLoaded(false);
+ router.replace("/");
+ }
+ } catch (error) {
+ toast({
+ title: "Error",
+ description:
+ "An error occurred while cancelling your subscription, please try again later.",
+ variant: "destructive",
+ });
+ setSelectedPlan("");
+ setIsLoaded(false);
+ }
+ };
+
+ const handleManageSubscription = async () => {
+ try {
+ setSelectedPlan("manage");
+ setIsLoaded(true);
+ const portalUrl = await manage();
+ router.push(portalUrl!);
+ setIsLoaded(false);
+ setSelectedPlan("");
+ } catch (error) {
+ setSelectedPlan("");
+ setIsLoaded(false);
+ toast({
+ title: "Error",
+ description: "An error occurred while processing your request",
+ variant: "destructive",
+ });
+ }
+ };
+
+ function SuspenseFallback() {
+ return (
+
+
+
+ );
+ }
+
+ if (!isFetching && success) {
+ return (
+
+
+
+
+ Thank you for subscribing!
+
+
+ You are now subscribed to the {planDetails.plan} plan.
+
+
+
+ );
+ }
+
+ return (
+ <>
+ }>
+
+
+ {isFetching && !isSubscribed ? (
+
+
+
+ ) : isSubscribed ? (
+
+
+
+
+
+
+
+
+
+
+ Your current plan
+
+
+
+
+ Plan details
+
+
+
+
+ Your current plan is the {planDetails.plan} plan{" "}
+ {
+ // display billed annually if the planDetails.endsOn date is greater than 30 days from now
+ planDetails.endsOn! >
+ Date.now() + 30 * 24 * 60 * 60 * 1000 ? (
+
+ Billed annually
+
+ ) : null
+ }
+
+ {
+
+ handleManageSubscription()}
+ title="Manage"
+ className="w-[fit-content] hover:brightness-[.85] bg-[--accent-color] text-white-1"
+ >
+ {isLoaded && selectedPlan === "manage" ? (
+
+ ) : null}
+ Manage
+
+
+ handleCancellation()}
+ title="Cancel"
+ className="w-[fit-content] hover:brightness-[.85] bg-red-500 text-white-1"
+ >
+ {isLoaded && selectedPlan === "cancel" ? (
+
+ ) : null}
+ Cancel
+
+
+ }
+
+
+
+ Your subscription ends on{" "}
+ {new Date(planDetails.endsOn!).toLocaleDateString()}
+
+
+
+
+
Usage
+
+
+
+ You have created {totalPodcasts} podcasts this month.
+
+
+ {podcastsRemaining === "unlimited" ||
+ podcastsRemaining - totalPodcasts! == 30 ? (
+
+ ) : podcastsRemaining > 10 ? (
+
+ ) : podcastsRemaining <= 10 && podcastsRemaining > 0 ? (
+
+ ) : podcastsRemaining === 0 ? (
+
+ ) : null}
+ You have {podcastsRemaining} podcasts remaining for the
+ month of{" "}
+ {new Date().toLocaleString("default", { month: "long" })}.
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ Choose a plan that works for you
+
+
+
+
+ setAnnual(false)}
+ className={` ${annual ? "" : "bg-[#00000080]"} font-medium rounded-full transition h-12 w-full py-2 block px-8 text-xs text-white-1`}
+ >
+ Monthly
+
+ setAnnual(true)}
+ className={` ${annual ? "bg-[#00000080]" : ""} font-medium rounded-full transition h-12 w-full py-2 block px-8 text-xs border-white text-white-1`}
+ type="button"
+ >
+ Annual
+
+
+
+
+
+ {pricingPlans.map((plan) => (
+
+
+
+
+ {plan.name}
+
+
+
+
+ ${annual ? plan.annualPrice : plan.monthlyPrice}
+
+
+
+ /m
+
+ {annual &&
+ plan.annualPrice !== "0" &&
+ " (billed annually)"}
+
+
+
+
+
+ {plan.description}
+
+
+ {plan.features.map((feature) => (
+
+
+
+
+
+
+ {feature}
+
+ ))}
+ {plan.unavailableFeatures.map((feature) => (
+
+
+
+
+
+
+ {feature}
+
+ ))}
+
+
+
+ handleUpgrade(plan.name)}
+ title={plan.name}
+ aria-label="get started"
+ className={`flex items-center justify-center w-full h-12 px-4 py-2 text-base font-medium transition-all duration-200 rounded-xl ${plan.buttonClass}`}
+ >
+ {isLoaded && plan.name === selectedPlan ? (
+
+ ) : (
+ "Get started"
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/components/ButtonSpinner.tsx b/components/ButtonSpinner.tsx
new file mode 100644
index 0000000..ccf75d0
--- /dev/null
+++ b/components/ButtonSpinner.tsx
@@ -0,0 +1,25 @@
+export default function ButtonSpinner() {
+ return (
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/GenerateThumbnail.tsx b/components/GenerateThumbnail.tsx
index 3c67e9a..dac855b 100644
--- a/components/GenerateThumbnail.tsx
+++ b/components/GenerateThumbnail.tsx
@@ -1,10 +1,10 @@
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button'
import { cn } from '@/lib/utils';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { GenerateThumbnailProps } from '@/types';
-import { Loader } from 'lucide-react';
+import { Loader , Lock, LockKeyhole} from 'lucide-react';
import { Input } from './ui/input';
import Image from 'next/image';
import { useToast } from './ui/use-toast';
@@ -12,6 +12,14 @@ import { useAction, useMutation } from 'convex/react';
import { useUploadFiles } from '@xixixao/uploadstuff/react';
import { api } from '@/convex/_generated/api';
import { v4 as uuidv4 } from 'uuid';
+import { useGetPlan, useIsSubscribed } from '@/hooks/useIsSubscribed';
+import { useClerk } from '@clerk/nextjs';
+
+type planDetails = {
+ subscriptionId: string;
+ endsOn: number;
+ plan: string;
+};
const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, setImagePrompt }: GenerateThumbnailProps) => {
const [isAiThumbnail, setIsAiThumbnail] = useState(false);
@@ -21,7 +29,13 @@ const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, se
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const { startUpload } = useUploadFiles(generateUploadUrl)
const getImageUrl = useMutation(api.podcasts.getUrl);
- const handleGenerateThumbnail = useAction(api.openai.generateThumbnailAction)
+
+
+ const { user } = useClerk();
+ const isSubscribed = useIsSubscribed(user?.id!);
+ const { plan } = useGetPlan(user?.id!) as planDetails;
+
+ const handleGenerateThumbnail = useAction(api.openai.generateThumbnailAction);
const handleImage = async (blob: Blob, fileName: string) => {
setIsImageLoading(true);
@@ -88,31 +102,66 @@ const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, se
}
}
+ useEffect(() => {
+ if (!isSubscribed) {
+ setIsAiThumbnail(false);
+ }
+ }, [isSubscribed])
+
+
+ function handleGenerateButton(state: boolean) {
+ // do not allow the isAiThumbnail to be true if the user is not a subscriber
+ if ((!isSubscribed || plan === "FREE")) {
+ setIsAiThumbnail(false);
+ toast({
+ title: "Please subscribe to use this feature",
+ })
+
+ return;
+ }
+
+ setIsAiThumbnail(state);
+ }
+
return (
<>
setIsAiThumbnail(true)}
- className={cn('', {
- 'bg-black-6': isAiThumbnail
+ disabled={isSubscribed && plan === "FREE"}
+ onClick={() => handleGenerateButton(true)}
+ className={cn(`${
+ isAiThumbnail ? "bg-black-6" : "" } ${
+ !isSubscribed ? "cursor-not-allowed" : ""
+ }`, {
+ "bg-black-6": isAiThumbnail,
})}
>
- Use AI to generate thumbnail
+ {
+ // show a lock icon if the user is not a subscriber
+ !isSubscribed || plan === "FREE" ? (
+
+
+ Use AI to generate thumbnail
+
+ ) : (
+ "Use AI to generate thumbnail"
+ )
+ }
setIsAiThumbnail(false)}
- className={cn('', {
- 'bg-black-6': !isAiThumbnail
+ className={cn("", {
+ "bg-black-6": !isAiThumbnail,
})}
>
Upload custom image
- {isAiThumbnail ? (
+ {isAiThumbnail && isSubscribed ? (
@@ -120,23 +169,27 @@ const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, se
setImagePrompt(e.target.value)}
/>
-
- {isImageLoading ? (
- <>
- Generating
-
- >
- ) : (
- 'Generate'
- )}
-
+
+ {isImageLoading ? (
+ <>
+ Generating
+
+ >
+ ) : (
+ "Generate"
+ )}
+
) : (
@@ -148,18 +201,25 @@ const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, se
onChange={(e) => uploadImage(e)}
/>
{!isImageLoading ? (
-
- ): (
+
+ ) : (
Uploading
)}
-
- Click to upload
+
+ Click to upload
- SVG, PNG, JPG, or GIF (max. 1080x1080px)
+
+ SVG, PNG, JPG, or GIF (max. 1080x1080px)
+
)}
@@ -175,7 +235,7 @@ const GenerateThumbnail = ({ setImage, setImageStorageId, image, imagePrompt, se
)}
>
- )
+ );
}
export default GenerateThumbnail
\ No newline at end of file
diff --git a/components/LeftSidebar.tsx b/components/LeftSidebar.tsx
index c771a9a..014ccac 100644
--- a/components/LeftSidebar.tsx
+++ b/components/LeftSidebar.tsx
@@ -15,6 +15,7 @@ const LeftSidebar = () => {
const router = useRouter();
const { signOut } = useClerk();
const { audio } = useAudio();
+ const { user } = useClerk();
return (
{
{sidebarLinks.map(({ route, label, imgURL }) => {
const isActive = pathname === route || pathname.startsWith(`${route}/`);
- return
diff --git a/components/ProfileCard.tsx b/components/ProfileCard.tsx
index 6729e83..e6dc2ab 100644
--- a/components/ProfileCard.tsx
+++ b/components/ProfileCard.tsx
@@ -7,11 +7,19 @@ import { PodcastProps, ProfileCardProps } from "@/types";
import LoaderSpinner from "./LoaderSpinner";
import { Button } from "./ui/button";
+import { useIsSubscribed, useGetPlan } from "@/hooks/useIsSubscribed";
+
+type planDetails = {
+ subscriptionId: string;
+ endsOn: number;
+ plan: string;
+};
const ProfileCard = ({
podcastData,
imageUrl,
userFirstName,
+ profileId,
}: ProfileCardProps) => {
const { setAudio } = useAudio();
@@ -23,6 +31,9 @@ const ProfileCard = ({
setRandomPodcast(podcastData.podcasts[randomIndex]);
};
+ const isSubscribed = useIsSubscribed(profileId);
+ const { plan } = useGetPlan(profileId) as planDetails;
+
useEffect(() => {
if (randomPodcast) {
setAudio({
@@ -51,18 +62,32 @@ const ProfileCard = ({
Verified Creator
-
- {userFirstName}
-
+
+
+ {userFirstName}
+
+ {isSubscribed && (
+
+
+ {plan}
+
+ )}
+
+
void }) {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { user } = useClerk();
+ const isSubscribed = useIsSubscribed(user?.id ?? "");
+ const isFetching = useIsFetching();
+
+ // if the payment was successful, the user will be redirected to the plans page with a query parameter.
+ // This block checks for the query parameter and displays a success message to the user.
+ useEffect(() => {
+ if (!isFetching && searchParams.get("session_id") && isSubscribed) {
+ setSuccess(true);
+
+ toast({
+ title: "Payment successful ✅",
+ variant: "success",
+ });
+ }
+ // remove the query parameter from the URL and update the state after 5 seconds.
+ setTimeout(() => {
+ router.replace("/plans");
+ setSuccess(false);
+ }, 5000);
+ }, [isFetching, isSubscribed, router, searchParams, setSuccess, toast]);
+
+ return null;
+}
\ No newline at end of file
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 9d065aa..1b7a04b 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -32,6 +32,8 @@ const toastVariants = cva(
default: "border bg-[--accent-color] text-white-1",
destructive:
"destructive border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900",
+ success: "success border-green-500 bg-green-600 text-slate-50",
+ info: "info border-yellow-500 bg-yellow-500 text-slate-50",
},
},
defaultVariants: {
diff --git a/constants/index.ts b/constants/index.ts
index adfdc1c..6e73119 100644
--- a/constants/index.ts
+++ b/constants/index.ts
@@ -14,6 +14,16 @@ export const sidebarLinks = [
route: "/create-podcast",
label: "Create Podcast",
},
+ {
+ imgURL: "/icons/profile.svg",
+ route: "/profile",
+ label: "Profile",
+ },
+ {
+ imgURL: "/icons/chart.svg",
+ route: "/plans",
+ label: "Billing & Usage",
+ }
];
export const voiceDetails = [
@@ -41,4 +51,81 @@ export const voiceDetails = [
id: 6,
name: "shimmer",
},
+];
+
+// This block maps over the pricingPlans array to generate pricing plan cards.
+// Each card displays information based on whether the 'annual' state is true or false,
+// which toggles between showing monthly and annual prices.
+// Features and unavailable features are listed with icons indicating their availability.
+export const pricingPlans = [
+ {
+ // Name of the pricing plan.
+ name: "Free",
+ // Monthly price of the plan as a string.
+ monthlyPrice: "0",
+ // Annual price of the plan as a string, representing cost per month when billed annually.
+ annualPrice: "0",
+ // A short description for your pricing table
+ description:
+ "This plan is ideal for individual users and hobbyists who are looking for essential functionalities to get started and explore the platform.",
+ // Tailwind CSS classes for styling the card's background.
+ cardBgClass: "bg-[#0003] backdrop-blur-3xl",
+ // Tailwind CSS classes for styling the button within the card.
+ buttonClass: "text-white-1 bg-[#ffffff1a] hover:bg-[#ffffff0d] ",
+ // Array of features included in the plan.
+ features: [
+ "Create upto 5 Podcasts per month",
+ "Unlimited Listening",
+ "Text-to-speech",
+ "Thumbnail upload",
+ "1 AI voice model",
+ ],
+ // Array of features not available in the plan.
+ unavailableFeatures: [
+ "30 Podcasts per month",
+ "AI Thumbnail generation",
+ "AI voice model selection",
+ ],
+ },
+ {
+ name: "Pro",
+ monthlyPrice: "15",
+ annualPrice: "10",
+ description:
+ "If you're a small business or a startup, this plan is designed to cater to your needs. It offers a balance of essential features.",
+ cardBgClass: "bg-[#00000080] backdrop-blur-3xl",
+ buttonClass:
+ "text-black-1 bg-[#ffffff] hover:bg-[#ffffff0d] hover:text-white-1 ",
+ features: [
+ "Create upto 30 Podcasts per month",
+ "AI Thumbnail generation",
+ "AI voice model selection",
+ "Up to 6 AI voice models",
+ "Unlimited Listening",
+ ],
+ unavailableFeatures: [
+ "Unlimited Podcasts per month",
+ "Unlimited AI Thumbnail generation",
+ ],
+ },
+ {
+ name: "Enterprise",
+ monthlyPrice: "55",
+ annualPrice: "50",
+ description:
+ "Tailored for medium-sized businesses, this plan offers advanced tools and features to support your growing demands.",
+ cardBgClass: "bg-[#0003] backdrop-blur-3xl",
+ buttonClass: "text-white-1 bg-[#ffffff1a] hover:bg-[#ffffff0d] ",
+ features: [
+ "Unlimited Podcasts per month",
+ "Unlimited AI Thumbnail generation",
+ "AI voice model selection",
+ "Podcast boost",
+ "Gpt-3.5-turbo-16k model",
+ "Podcast stats and analytics",
+ "Unlimited Listening",
+ ],
+ // The third plan does not have any unavailable features, hence an empty array.
+ unavailableFeatures: [],
+ },
];
\ No newline at end of file
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index b4c38a1..df88ca7 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -14,12 +14,15 @@ import type {
FilterApi,
FunctionReference,
} from "convex/server";
+import type * as crons from "../crons.js";
import type * as files from "../files.js";
import type * as http from "../http.js";
import type * as openai from "../openai.js";
+import type * as payments from "../payments.js";
import type * as podcasts from "../podcasts.js";
-import type * as tasks from "../tasks.js";
+import type * as stripe from "../stripe.js";
import type * as users from "../users.js";
+import type * as util from "../util.js";
/**
* A utility for referencing Convex functions in your app's API.
@@ -30,12 +33,15 @@ import type * as users from "../users.js";
* ```
*/
declare const fullApi: ApiFromModules<{
+ crons: typeof crons;
files: typeof files;
http: typeof http;
openai: typeof openai;
+ payments: typeof payments;
podcasts: typeof podcasts;
- tasks: typeof tasks;
+ stripe: typeof stripe;
users: typeof users;
+ util: typeof util;
}>;
export declare const api: FilterApi<
typeof fullApi,
diff --git a/convex/crons.ts b/convex/crons.ts
new file mode 100644
index 0000000..5479c61
--- /dev/null
+++ b/convex/crons.ts
@@ -0,0 +1,13 @@
+import { cronJobs } from "convex/server";
+import { internal } from "./_generated/api";
+
+const crons = cronJobs();
+
+// reset the totalPodcasts count every month
+crons.cron(
+ "reset total podcasts count of all users every month",
+ "0 0 1 * *", // every month
+ internal.users.resetTotalPodcastsCron
+);
+
+export default crons;
\ No newline at end of file
diff --git a/convex/http.ts b/convex/http.ts
index e6af649..0ff6f71 100644
--- a/convex/http.ts
+++ b/convex/http.ts
@@ -49,6 +49,29 @@ http.route({
handler: handleClerkWebhook,
});
+http.route({
+ path: "/stripe",
+ method: "POST",
+ handler: httpAction(async (ctx, request) => {
+ const signature: string = request.headers.get("stripe-signature") as string;
+
+ const result = await ctx.runAction(internal.stripe.fulfill, {
+ signature,
+ payload: await request.text(),
+ });
+
+ if (result.success) {
+ return new Response(null, {
+ status: 200,
+ });
+ } else {
+ return new Response("Webhook Error", {
+ status: 400,
+ });
+ }
+ }),
+});
+
const validateRequest = async (
req: Request
): Promise => {
diff --git a/convex/openai.ts b/convex/openai.ts
index 60cd2b7..7b8791d 100644
--- a/convex/openai.ts
+++ b/convex/openai.ts
@@ -1,20 +1,93 @@
-import { action } from "./_generated/server";
+import { ActionCtx, action, internalMutation, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import OpenAI from "openai";
import { SpeechCreateParams } from "openai/resources/audio/speech.mjs";
+import { getUser, getUserById, isUserSubscribed } from "./users";
+import { useQuery } from "convex/react";
+import { api, internal } from "./_generated/api";
+import { rateLimit, checkRateLimit, resetRateLimit } from "@/lib/rateLimits";
+import { UserIdentity } from "convex/server";
+
+/**
+ * TODO: Add caching to user openai actions and check if the latest prompt has already been generated before
+ * if it has, return the same image or audio file
+ */
+
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
+const GENERATE_THUMBNAIL_ACTION = "generateThumbnailAction";
+const GENERATE_AUDIO_ACTION = "generateAudioAction";
+
+
+async function handleLimitations(ctx: ActionCtx, user: UserIdentity, voice: string | undefined) {
+ if (!user) {
+ throw new Error("User not authenticated");
+ }
+
+ // implement rate limiting
+ const rateLimit = await ctx.runMutation(internal.openai.handleRateLimits, {
+ userId: user.subject,
+ type: voice ? GENERATE_AUDIO_ACTION : GENERATE_THUMBNAIL_ACTION,
+ });
+
+ await checkPodcastCount(ctx);
+
+ // if no rate limit is returned, throw an error
+ if (!rateLimit) {
+ throw new Error("Rate limit exceeded, try again later");
+ }
+
+ if (!rateLimit.ok && rateLimit.retryAt) {
+ // add some jitter to the retry time to avoid thundering herd problem
+ const withJitter = rateLimit.retryAt + Math.random() * 10 * 1000;
+ const retryAt = new Date(withJitter).toLocaleTimeString(
+ // in IST timezone
+ "en-US",
+ { timeZone: "Asia/Kolkata" }
+ );
+ throw new Error("Rate limit exceeded, try after: " + retryAt + " (IST)");
+ }
+
+ // get user subscription
+ const { isSubscribed } = await ctx.runQuery(
+ internal.openai.getUserSubscription,
+ {}
+ );
+
+ // only allow users with a plan to generate thumbnails
+ if (!voice && !isSubscribed) {
+ throw new Error("User must have a subscription to generate thumbnails");
+ }
+
+ // only allow users with a plan to use other voice options than the default
+ if (voice && !isSubscribed && voice !== "alloy") {
+ throw new Error("User must have a subscription to use other voice options");
+ } else {
+ return isSubscribed;
+ }
+}
export const generateAudioAction = action({
args: { input: v.string(), voice: v.string() },
- handler: async (_, { voice, input }) => {
+ handler: async (ctx, { voice, input }) => {
+
+ const user = await ctx.auth.getUserIdentity();
+
+ if (!user) {
+ throw new Error("User not authenticated");
+ }
+
+ const defaultVoice = "alloy" as SpeechCreateParams["voice"];
+
+ const isSubscribed = await handleLimitations(ctx, user, voice);
+
const mp3 = await openai.audio.speech.create({
model: "tts-1",
- voice: voice as SpeechCreateParams["voice"],
+ voice: isSubscribed? voice as SpeechCreateParams["voice"] : defaultVoice,
input,
});
@@ -24,11 +97,101 @@ export const generateAudioAction = action({
},
});
+
+export const handleRateLimits = internalMutation({
+ args: { userId: v.string(), type: v.string() },
+ handler: async (ctx, args) => {
+
+ switch (args.type) {
+ case GENERATE_AUDIO_ACTION:
+ return await rateLimit(ctx, {
+ name: args.type,
+ key: args.userId,
+ throws: false,
+ });
+
+ case GENERATE_THUMBNAIL_ACTION:
+ return await rateLimit(ctx, {
+ name: args.type,
+ key: args.userId,
+ throws: false,
+ });
+ default:
+ return;
+ }
+ },
+});
+
+
+async function checkPodcastCount(ctx: ActionCtx) {
+
+ const user = await ctx.runQuery(api.users.getUser);
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ switch (user.plan?.toLowerCase()) {
+ case "free":
+ // check if user has generated more than 5 podcasts
+ if (user.totalPodcasts >= 5) {
+ throw new Error("Free users can only generate 5 podcasts per month");
+ }
+ return;
+ case "pro":
+ // check if user has generated more than 30 podcasts
+ if (user.totalPodcasts >= 30) {
+ throw new Error("Pro users can only generate 30 podcasts per month");
+ }
+ return;
+ case "enterprise":
+ // check if user has generated more than 100 podcasts
+ if (user.totalPodcasts >= 100) {
+ throw new Error("Enterprise users can only generate 100 podcasts per month");
+ }
+ return;
+ default:
+ // check if user has generated more than 5 podcasts
+ if (user.totalPodcasts >= 5) {
+ throw new Error("Free users can only generate 5 podcasts per month");
+ }
+ return;
+ }
+}
+
+// function to get user subscription details
+export const getUserSubscription = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+
+ const identity = await ctx.auth.getUserIdentity();
+
+ const user = await getUserById(ctx, { clerkId: identity?.subject! });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ return {
+ isSubscribed: await isUserSubscribed(ctx),
+ plan: user.plan,
+ };
+ },
+});
+
export const generateThumbnailAction = action({
args: { prompt: v.string() },
- handler: async (_, { prompt }) => {
+ handler: async (ctx, { prompt }) => {
+ const user = await ctx.auth.getUserIdentity();
+
+ if (!user) {
+ throw new Error("User not authenticated");
+ }
+
+ await handleLimitations(ctx, user, undefined);
+
const response = await openai.images.generate({
- model: "dall-e-3",
+ model: "dall-e-2",
prompt,
size: "1024x1024",
quality: "standard",
diff --git a/convex/payments.ts b/convex/payments.ts
new file mode 100644
index 0000000..231b871
--- /dev/null
+++ b/convex/payments.ts
@@ -0,0 +1,30 @@
+import { v } from "convex/values";
+import { internalMutation, query } from "./_generated/server";
+
+export const create = internalMutation({
+ handler: async (ctx) => {
+ return await ctx.db.insert("payments", {});
+ },
+});
+
+export const markPending = internalMutation({
+ args: { paymentId: v.id("payments"), stripeId: v.string() },
+ handler: async (ctx, { paymentId, stripeId }) => {
+ await ctx.db.patch(paymentId, { stripeId });
+ },
+});
+
+export const fulfill = internalMutation({
+ args: {
+ stripeId: v.string(),
+ customerId: v.string(),
+ userId: v.string(),
+ },
+ handler: async (ctx, { stripeId, customerId, userId }) => {
+ const { _id: paymentId } = (await ctx.db
+ .query("payments")
+ .withIndex("stripeId", (q) => q.eq("stripeId", stripeId))
+ .unique())!;
+ await ctx.db.patch(paymentId, {stripeId, customerId, userId});
+ },
+});
diff --git a/convex/podcasts.ts b/convex/podcasts.ts
index 7c4e432..7ab3458 100644
--- a/convex/podcasts.ts
+++ b/convex/podcasts.ts
@@ -1,6 +1,18 @@
import { ConvexError, v } from "convex/values";
-import { mutation, query } from "./_generated/server";
+import { MutationCtx, mutation, query } from "./_generated/server";
+
+type User = {
+ _id: string;
+ email: string;
+ imageUrl: string;
+ clerkId: string;
+ name: string;
+ subscriptionId?: string;
+ endsOn?: number;
+ plan?: string;
+ totalPodcasts: number;
+};
// create podcast mutation
export const createPodcast = mutation({
@@ -33,7 +45,14 @@ export const createPodcast = mutation({
throw new ConvexError("User not found");
}
- return await ctx.db.insert("podcasts", {
+ const isSubscribed =
+ user[0].subscriptionId && user[0].endsOn && user[0].endsOn > new Date().getTime();
+
+ const voiceType = isSubscribed ? args.voiceType : "alloy";
+
+ await handlePodcastSubscription(ctx, user[0]);
+
+ const newPodcast = await ctx.db.insert("podcasts", {
audioStorageId: args.audioStorageId,
user: user[0]._id,
podcastTitle: args.podcastTitle,
@@ -45,14 +64,53 @@ export const createPodcast = mutation({
authorId: user[0].clerkId,
voicePrompt: args.voicePrompt,
imagePrompt: args.imagePrompt,
- voiceType: args.voiceType,
+ voiceType: voiceType,
views: args.views,
authorImageUrl: user[0].imageUrl,
audioDuration: args.audioDuration,
});
+
+ // update the totalPodcasts of the user
+ await ctx.db.patch(user[0]._id, {
+ totalPodcasts: user[0].totalPodcasts + 1,
+ });
+
+ return newPodcast;
},
});
+// function to handle the podcasts based on user subscription
+export const handlePodcastSubscription = async (ctx: MutationCtx,
+ user: User
+) => {
+
+ const plan = user.plan? user.plan : "FREE";
+
+ switch (plan.toUpperCase()) {
+ case "FREE":
+ if (user.totalPodcasts >= 5) {
+ throw new ConvexError("You have exceeded the limit of podcasts for this month");
+ }
+ break;
+ case "PRO":
+ if (user.totalPodcasts >= 30) {
+ throw new ConvexError("You have exceeded the limit of podcasts for this month");
+ }
+ break;
+ case "ENTERPRISE":
+ if (user.totalPodcasts >= 100) {
+ throw new ConvexError("You have exceeded the limit of podcasts for this month");
+ }
+ break;
+ default:
+ if (user.totalPodcasts >= 5) {
+ throw new ConvexError("You have exceeded the limit of podcasts for this month");
+ }
+ break;
+ }
+
+};
+
// this mutation is required to generate the url after uploading the file to the storage.
export const getUrl = mutation({
args: {
diff --git a/convex/schema.ts b/convex/schema.ts
index fa16717..4e4849c 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -1,15 +1,17 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
+import { rateLimitTables } from "convex-helpers/server/rateLimit";
export default defineSchema({
+ ...rateLimitTables,
podcasts: defineTable({
- user: v.id('users'),
+ user: v.id("users"),
podcastTitle: v.string(),
podcastDescription: v.string(),
audioUrl: v.optional(v.string()),
- audioStorageId: v.optional(v.id('_storage')),
+ audioStorageId: v.optional(v.id("_storage")),
imageUrl: v.optional(v.string()),
- imageStorageId: v.optional(v.id('_storage')),
+ imageStorageId: v.optional(v.id("_storage")),
author: v.string(),
authorId: v.string(),
authorImageUrl: v.string(),
@@ -19,13 +21,26 @@ export default defineSchema({
audioDuration: v.number(),
views: v.number(),
})
- .searchIndex('search_author', { searchField: 'author' })
- .searchIndex('search_title', { searchField: 'podcastTitle' })
- .searchIndex('search_body', { searchField: 'podcastDescription' }),
+ .searchIndex("search_author", { searchField: "author" })
+ .searchIndex("search_title", { searchField: "podcastTitle" })
+ .searchIndex("search_body", { searchField: "podcastDescription" }),
users: defineTable({
email: v.string(),
imageUrl: v.string(),
clerkId: v.string(),
name: v.string(),
+ subscriptionId: v.optional(v.string()),
+ customerId: v.optional(v.string()),
+ endsOn: v.optional(v.number()),
+ plan: v.optional(v.string()),
+ totalPodcasts: v.number(),
})
-})
\ No newline at end of file
+ .index("by_clerkId", ["clerkId"])
+ .index("by_subscriptionId", ["subscriptionId"]),
+ payments: defineTable({
+ stripeId: v.optional(v.string()),
+ customerId: v.optional(v.string()),
+ userId: v.optional(v.string()),
+
+ }).index("stripeId", ["stripeId"]),
+});
\ No newline at end of file
diff --git a/convex/stripe.ts b/convex/stripe.ts
new file mode 100644
index 0000000..73494fe
--- /dev/null
+++ b/convex/stripe.ts
@@ -0,0 +1,281 @@
+"use node";
+
+import { v } from "convex/values";
+import { ActionCtx, action, internalAction, internalMutation, mutation } from "./_generated/server";
+import Stripe from "stripe";
+import { api, internal } from "./_generated/api";
+
+type Metadata = {
+ userId: string;
+};
+
+export const pay = action({
+ args: { plan: v.string() },
+ handler: async (ctx, args) => {
+ const user = await ctx.auth.getUserIdentity();
+
+ if (!user) {
+ throw new Error("You must be logged in to subscribe!");
+ }
+
+ if (!user.emailVerified) {
+ throw new Error("You must have a verified email to subscribe!");
+ }
+
+ if (!args.plan) {
+ throw new Error("You must provide a plan to subscribe to!");
+ }
+
+ const domain = process.env.HOSTING_URL ?? "http://localhost:3000";
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-04-10",
+ });
+
+ let priceId = "";
+
+ switch (args.plan) {
+ case "Pro":
+ priceId = process.env.PRICE_ID_PRO!;
+ break;
+ case "Enterprise":
+ priceId = process.env.PRICE_ID_ENTERPRISE!;
+ break;
+ case "Pro-annual":
+ priceId = process.env.PRICE_ID_PRO_ANNUAL!;
+ break;
+ case "Enterprise-annual":
+ priceId = process.env.PRICE_ID_ENTERPRISE_ANNUAL!;
+ break;
+ default:
+ throw new Error("Invalid plan provided!");
+ }
+
+ const paymentId = await ctx.runMutation(internal.payments.create);
+ const session = await stripe.checkout.sessions.create({
+ ui_mode: "hosted",
+ line_items: [{ price: priceId, quantity: 1 }],
+ customer_email: user.email,
+ metadata: {
+ userId: user.subject,
+ },
+ mode: "subscription",
+ success_url: `${domain}/plans?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${domain}`,
+ });
+
+ await ctx.runMutation(internal.payments.markPending, {
+ paymentId,
+ stripeId: session.id,
+ });
+ return session.url;
+ },
+});
+
+export const cancelSubscription = action({
+ args: { clerkId: v.string() },
+ handler: async (ctx, args) => {
+ const user = await ctx.auth.getUserIdentity();
+
+ if (!user) {
+ throw new Error("You must be logged in to cancel your subscription!");
+ }
+
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-04-10",
+ });
+
+ const { subscriptionId } = await ctx.runQuery(
+ api.users.getSubscriptionByClerkId,
+ {
+ clerkId: user.subject,
+ }
+ );
+
+ if (!subscriptionId) {
+ throw new Error("No subscription found for this user!");
+ }
+
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+
+ if (subscription.status === "canceled") {
+ throw new Error("Subscription already canceled!");
+ }
+
+ await stripe.subscriptions.cancel(subscriptionId);
+
+ await ctx.runMutation(internal.users.updateSubscription, {
+ userId: user.subject,
+ subscriptionId: undefined,
+ endsOn: 0,
+ plan: "Free",
+ });
+
+ return { success: true };
+ },
+});
+
+export const createCustomerPortal = action({
+ args: {},
+ handler: async (ctx, args) => {
+ const user = await ctx.auth.getUserIdentity();
+
+ if (!user) {
+ throw new Error("You must be logged in to access the customer portal!");
+ }
+
+ const { customerId } = await ctx.runQuery(
+ api.users.getSubscriptionByClerkId,
+ {
+ clerkId: user.subject,
+ }
+ );
+
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-04-10",
+ });
+
+ const session = await stripe.billingPortal.sessions.create({
+ customer: customerId!,
+ return_url:
+ process.env.HOSTING_URL ??
+ `http://localhost:3000/plans?session_id={BILLING_PORTAL_SESSION_ID}`,
+ });
+
+ return session.url;
+ },
+});
+
+async function getPlanNameFromProductId(productId: string) {
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-04-10",
+ });
+
+ const product = await stripe.products.retrieve(productId);
+
+ return product.name;
+}
+
+async function handleEvents(
+ stripe: Stripe,
+ ctx: ActionCtx,
+ event: Stripe.Event
+) {
+ const completedEvent = event.data.object as Stripe.Checkout.Session & {
+ metadata: Metadata;
+ };
+
+ const updateEvent = event.data.object as Stripe.Subscription & {
+ metadata: Metadata;
+ };
+
+ let subscriptionId = completedEvent.subscription as string | undefined;
+ let subscription = {} as Stripe.Subscription;
+
+ if (subscriptionId) {
+ subscription = await stripe.subscriptions.retrieve(
+ completedEvent.subscription as string
+ );
+ }
+
+ try {
+
+ switch (event.type) {
+ case "checkout.session.completed":
+ const stripeId = (event.data.object as { id: string }).id;
+ const userId = completedEvent.metadata.userId;
+ const customerId = completedEvent.customer as string;
+
+ await ctx.runMutation(internal.users.updateSubscription, {
+ userId,
+ subscriptionId: subscription.id,
+ endsOn: subscription.current_period_end * 1000,
+ plan: await getPlanNameFromProductId(
+ subscription.items.data[0]?.price.product as string
+ ),
+ customerId: customerId,
+ });
+ await ctx.runMutation(internal.payments.fulfill, { stripeId, customerId, userId});
+ break;
+
+ case "invoice.payment_succeeded":
+ await ctx.runMutation(internal.users.updateSubscriptionBySubId, {
+ subscriptionId: subscription.items.data[0]?.price.id,
+ endsOn: subscription.current_period_end * 1000,
+ customerId: subscription.customer as string,
+ plan: await getPlanNameFromProductId(
+ subscription.items.data[0]?.price.product as string
+ ),
+ });
+ break;
+
+ case "customer.subscription.updated":
+ await ctx.runMutation(internal.users.updateSubscriptionBySubId, {
+ subscriptionId: updateEvent.id,
+ endsOn: updateEvent.current_period_end * 1000, // the subscription ends on the current_period_end date regardless of the cancel_at date
+ customerId: updateEvent.customer as string,
+ plan: await getPlanNameFromProductId(
+ updateEvent.items.data[0].price.product as string
+ ),
+ });
+ // schedule a function to change the user's plan to "Free" after the cancel_at date
+ if (updateEvent.cancel_at) {
+ await ctx.scheduler.runAt(
+ new Date(updateEvent.cancel_at * 1000),
+ internal.users.updateSubscriptionBySubId,
+ {
+ subscriptionId: updateEvent.id,
+ endsOn: 0,
+ customerId: updateEvent.customer as string,
+ plan: "Free",
+ }
+ );
+ }
+ /**
+ * TODO: Add cancel scheduled task if the subscription is reactivated before the cancel_at date
+ */
+ break;
+ case "customer.subscription.deleted":
+ await ctx.runMutation(internal.users.updateSubscriptionBySubId, {
+ subscriptionId: updateEvent.id,
+ endsOn: updateEvent.current_period_end * 1000,
+ customerId: completedEvent.customer as string,
+ });
+ break;
+
+ /**
+ * TODO: Add "customer.updated" event to update the user's (billing) email in the database
+ */
+
+ default:
+ break;
+ }
+ return { success: true };
+ } catch (error) {
+ console.error("Error processing event: ", error);
+ return { success: false, error: (error as { message: string }).message };
+ }
+}
+
+export const fulfill = internalAction({
+ args: { signature: v.string(), payload: v.string() },
+ handler: async (ctx, args) => {
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-04-10",
+ });
+
+ const webhookSecret = process.env.STRIPE_WEBHOOKS_SECRET as string;
+
+ try {
+ const event = stripe.webhooks.constructEvent(
+ args.payload,
+ args.signature,
+ webhookSecret
+ );
+
+ return await handleEvents(stripe, ctx, event);
+ } catch (err) {
+ console.error(err);
+ return { success: false, error: (err as { message: string }).message };
+ }
+ },
+});
diff --git a/convex/tasks.ts b/convex/tasks.ts
deleted file mode 100644
index d8186b8..0000000
--- a/convex/tasks.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { query } from "./_generated/server";
-
-export const get = query({
- args: {},
- handler: async (ctx) => {
- return await ctx.db.query("tasks").collect();
- },
-});
\ No newline at end of file
diff --git a/convex/users.ts b/convex/users.ts
index 0debee5..5c3ea56 100644
--- a/convex/users.ts
+++ b/convex/users.ts
@@ -1,6 +1,36 @@
import { ConvexError, v } from "convex/values";
-import { internalMutation, query } from "./_generated/server";
+import { MutationCtx, QueryCtx, internalMutation, query } from "./_generated/server";
+import { getUserId } from "./util";
+import { internal } from "./_generated/api";
+
+
+export const getUser = query({
+ args: {},
+ handler: async (ctx, args) => {
+ const userId = await getUserId(ctx);
+
+ if (!userId) {
+ return undefined;
+ }
+
+ return getFullUser(ctx, userId);
+ },
+});
+
+// reset the totalPodcasts count every month
+export const resetTotalPodcastsCron = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const users = await ctx.db.query("users").collect();
+
+ await Promise.all(
+ users.map(async (u) => {
+ await ctx.db.patch(u._id, { totalPodcasts: 0 });
+ })
+ );
+ },
+});
export const getUserById = query({
args: { clerkId: v.string() },
@@ -18,6 +48,130 @@ export const getUserById = query({
},
});
+export function getFullUser(ctx: QueryCtx | MutationCtx, userId: string) {
+ return ctx.db
+ .query("users")
+ .withIndex("by_clerkId", (q) => q.eq("clerkId", userId))
+ .first();
+}
+
+export const isUserSubscribed = async (ctx: QueryCtx | MutationCtx) => {
+ const userId = await getUserId(ctx);
+
+ if (!userId) {
+ return false;
+ }
+
+ const userToCheck = await getFullUser(ctx, userId );
+
+ return (userToCheck?.endsOn ?? 0) > Date.now();
+};
+
+export const getSubscriptionByClerkId = query({
+ args: { clerkId: v.string() },
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("users")
+ .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
+ .first();
+
+ if (!user) {
+ throw new ConvexError("User not found");
+ }
+
+ return {
+ subscriptionId: user.subscriptionId,
+ endsOn: user.endsOn,
+ plan: user.plan,
+ customerId: user.customerId,
+ };
+ },
+});
+
+// export const getUserPlan = query({
+// args: {},
+// handler: async (ctx, args) => {
+// const userId = await getUserId(ctx);
+
+// if (!userId) {
+// return undefined;
+// }
+
+// const user = await getFullUser(ctx, userId);
+
+// return user?.plan;
+// },
+// });
+
+export const getTotalPodcastsOfUser = query({
+ args: { clerkId: v.string() },
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("users")
+ .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
+ .unique();
+
+ if (!user) {
+ throw new ConvexError("User not found");
+ }
+
+ return user.totalPodcasts;
+ },
+});
+
+
+export const updateSubscription = internalMutation({
+ args: {
+ subscriptionId: v.optional(v.string()),
+ userId: v.string(),
+ endsOn: v.number(),
+ plan: v.string(),
+ customerId: v.optional(v.string())
+ },
+ handler: async (ctx, args) => {
+ const user = await getFullUser(ctx, args.userId);
+
+ if (!user) {
+ throw new Error("no user found with that user id");
+ }
+
+ await ctx.db.patch(user._id, {
+ subscriptionId: args.subscriptionId,
+ endsOn: args.endsOn,
+ plan: args.plan,
+ customerId: args.customerId,
+ });
+ },
+});
+
+export const updateSubscriptionBySubId = internalMutation({
+ args: {
+ subscriptionId: v.optional(v.string()),
+ endsOn: v.optional(v.number()),
+ customerId: v.optional(v.string()),
+ plan: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("users")
+ .withIndex("by_subscriptionId", (q) =>
+ q.eq("subscriptionId", args.subscriptionId)
+ )
+ .first();
+
+ if (!user) {
+ throw new Error("no user found with that user id");
+ }
+
+ await ctx.db.patch(user._id, {
+ subscriptionId: args.subscriptionId,
+ endsOn: args.endsOn,
+ customerId: args.customerId,
+ plan: args.plan,
+ });
+ },
+});
+
// this query is used to get the top user by podcast count. first the podcast is sorted by views and then the user is sorted by total podcasts, so the user with the most podcasts will be at the top.
export const getTopUserByPodcastCount = query({
args: {},
@@ -61,6 +215,7 @@ export const createUser = internalMutation({
email: args.email,
imageUrl: args.imageUrl,
name: args.name,
+ totalPodcasts: 0,
});
},
});
diff --git a/convex/util.ts b/convex/util.ts
new file mode 100644
index 0000000..303290c
--- /dev/null
+++ b/convex/util.ts
@@ -0,0 +1,9 @@
+import { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
+
+export const getUserId = async (ctx: QueryCtx | MutationCtx | ActionCtx) => {
+ return (await ctx.auth.getUserIdentity())?.subject;
+};
+
+export const getUser = async (ctx: QueryCtx | MutationCtx | ActionCtx) => {
+ return await ctx.auth.getUserIdentity();
+};
diff --git a/hooks/useIsSubscribed.ts b/hooks/useIsSubscribed.ts
new file mode 100644
index 0000000..cabcc8d
--- /dev/null
+++ b/hooks/useIsSubscribed.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "convex/react";
+import { api } from "../convex/_generated/api";
+import { useIsFetching } from "@/providers/IsFetchingProvider";
+import { useEffect } from "react";
+
+export function useIsSubscribed(id: string) {
+
+ const { setIsFetching } = useIsFetching();
+
+ const user = useQuery(api.users.getSubscriptionByClerkId, { clerkId: id });
+
+ useEffect(() => {
+ if (!user) return;
+ setIsFetching(false);
+ }, [user]);
+
+ if (!user || !user.endsOn) return false;
+
+ return user?.endsOn > Date.now();
+}
+
+export function useGetPlan(id: string) {
+
+ const { setIsFetching } = useIsFetching();
+
+ const user = useQuery(api.users.getSubscriptionByClerkId, { clerkId: id });
+
+ useEffect(() => {
+ if (!user) return;
+ setIsFetching(false);
+ }, [user]);
+
+ if (!user || !user.plan) return "free";
+
+ return user;
+}
\ No newline at end of file
diff --git a/lib/rateLimits.ts b/lib/rateLimits.ts
new file mode 100644
index 0000000..0abe7a3
--- /dev/null
+++ b/lib/rateLimits.ts
@@ -0,0 +1,16 @@
+import { defineRateLimits } from "convex-helpers/server/rateLimit";
+
+const SECOND = 1000; // ms
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+export const { checkRateLimit, rateLimit, resetRateLimit } = defineRateLimits({
+ // A per-user limit, allowing one every ~6 seconds.
+ // Allows up to 3 in quick succession if they haven't sent many recently.
+ generateAudioAction: { kind: "fixed window", rate: 3, period: MINUTE*2 },
+ generateThumbnailAction: { kind: "fixed window", rate: 3, period: MINUTE*2 },
+ createPodcast: { kind: "fixed window", rate: 3, period: MINUTE*2 },
+ uploadFile: { kind: "fixed window", rate: 3, period: MINUTE*2 },
+
+});
diff --git a/package-lock.json b/package-lock.json
index 7ec0ed5..9f8873b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"convex": "^1.12.0",
+ "convex-helpers": "^0.1.43",
"embla-carousel": "^8.1.3",
"embla-carousel-autoplay": "^8.1.3",
"embla-carousel-react": "^8.1.3",
@@ -29,6 +30,7 @@
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.51.5",
+ "stripe": "^15.12.0",
"svix": "^1.24.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
@@ -2187,7 +2189,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
- "dev": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
@@ -2399,6 +2400,28 @@
}
}
},
+ "node_modules/convex-helpers": {
+ "version": "0.1.43",
+ "resolved": "https://registry.npmjs.org/convex-helpers/-/convex-helpers-0.1.43.tgz",
+ "integrity": "sha512-8Mka2+7b8/gN8Dexbk9c4iPaolBxl0pSTrQIN4swyq8wunMhtTJwoTEk79/VwQpUrEaqnmqS357ZMQWVluGv/A==",
+ "peerDependencies": {
+ "convex": "^1.12.0",
+ "hono": "^4.0.5",
+ "react": "^17.0.2 || ^18.0.0",
+ "zod": "^3.22.4"
+ },
+ "peerDependenciesMeta": {
+ "hono": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
@@ -2526,7 +2549,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
- "dev": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
@@ -2741,7 +2763,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
- "dev": true,
"dependencies": {
"get-intrinsic": "^1.2.4"
},
@@ -2753,7 +2774,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -3556,7 +3576,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
- "dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
@@ -3722,7 +3741,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
- "dev": true,
"dependencies": {
"get-intrinsic": "^1.1.3"
},
@@ -3763,7 +3781,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
- "dev": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
@@ -3775,7 +3792,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
- "dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -3787,7 +3803,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
- "dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -4762,7 +4777,6 @@
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz",
"integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==",
- "dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -5252,6 +5266,20 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.12.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz",
+ "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -5630,7 +5658,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
- "dev": true,
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
@@ -5681,7 +5708,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
"integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
- "dev": true,
"dependencies": {
"call-bind": "^1.0.7",
"es-errors": "^1.3.0",
@@ -5948,6 +5974,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "15.12.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.12.0.tgz",
+ "integrity": "sha512-slTbYS1WhRJXVB8YXU8fgHizkUrM9KJyrw4Dd8pLEwzKHYyQTIE46EePC2MVbSDZdE24o1GdNtzmJV4PrPpmJA==",
+ "dependencies": {
+ "@types/node": ">=8.1.0",
+ "qs": "^6.11.0"
+ },
+ "engines": {
+ "node": ">=12.*"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
diff --git a/package.json b/package.json
index 3aca1cd..001fdc8 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "stripe:listen": "stripe listen --forward-to https://youthful-seahorse-517.convex.site/stripe",
+ "stripe:trigger": "stripe trigger payment_intent.succeeded"
},
"dependencies": {
"@clerk/nextjs": "^5.1.2",
@@ -21,6 +23,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"convex": "^1.12.0",
+ "convex-helpers": "^0.1.43",
"embla-carousel": "^8.1.3",
"embla-carousel-autoplay": "^8.1.3",
"embla-carousel-react": "^8.1.3",
@@ -30,6 +33,7 @@
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.51.5",
+ "stripe": "^15.12.0",
"svix": "^1.24.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
diff --git a/providers/IsFetchingProvider.tsx b/providers/IsFetchingProvider.tsx
new file mode 100644
index 0000000..6047f5b
--- /dev/null
+++ b/providers/IsFetchingProvider.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useContext, createContext, useState, useEffect } from "react";
+import { IsFetchingContextType } from "@/types";
+
+// create a context for the isFetching state and a function to update it when the isSubscribed hook is called
+const IsFetchingContext = createContext(undefined);
+
+// create a provider to wrap the app in that will provide the isFetching state and the function to update it
+const IsFetchingProvider = ({ children }: { children: React.ReactNode }) => {
+ const [isFetching, setIsFetching] = useState(true);
+
+ return (
+
+ {children}
+
+ );
+};
+
+// create a hook to use the isFetching state and the function to update it
+export const useIsFetching = () => {
+ const context = useContext(IsFetchingContext);
+
+ if (!context) {
+ throw new Error("useIsFetching must be used within an IsFetchingProvider");
+ }
+
+ return context;
+};
+
+export default IsFetchingProvider;
\ No newline at end of file
diff --git a/public/icons/chart.svg b/public/icons/chart.svg
new file mode 100644
index 0000000..22e0f10
--- /dev/null
+++ b/public/icons/chart.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/icons/premium.svg b/public/icons/premium.svg
new file mode 100644
index 0000000..18b9f46
--- /dev/null
+++ b/public/icons/premium.svg
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/images/blueBlob.svg b/public/images/blueBlob.svg
new file mode 100644
index 0000000..3282688
--- /dev/null
+++ b/public/images/blueBlob.svg
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/types/index.ts b/types/index.ts
index 3ac5505..8a2d6f9 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -105,6 +105,11 @@ export interface AudioContextType {
setAudio: React.Dispatch>;
}
+export interface IsFetchingContextType {
+ isFetching: boolean;
+ setIsFetching: React.Dispatch>;
+}
+
export interface PodcastCardProps {
imgUrl: string;
title: string;
@@ -119,6 +124,7 @@ export interface CarouselProps {
export interface ProfileCardProps {
podcastData: ProfilePodcastProps;
imageUrl: string;
+ profileId: string;
userFirstName: string;
}