add paywall

This commit is contained in:
MarconLP 2023-04-20 19:25:09 +02:00
parent ff164bfe8e
commit 200c9c2612
No known key found for this signature in database
GPG key ID: A08A9C8B623F5EA5
5 changed files with 221 additions and 40 deletions

3
src/atoms/paywallAtom.ts Normal file
View file

@ -0,0 +1,3 @@
import { atom } from "jotai";
export default atom<boolean>(false);

167
src/components/Paywall.tsx Normal file
View file

@ -0,0 +1,167 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { useAtom } from "jotai";
import paywallAtom from "~/atoms/paywallAtom";
import { CloseIcon } from "next/dist/client/components/react-dev-overlay/internal/icons/CloseIcon";
import { api } from "~/utils/api";
import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/20/solid";
export default function Paywall() {
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
const router = useRouter();
const [open, setOpen] = useAtom(paywallAtom);
const [billedAnnually, setBilledAnnually] = useState<boolean>(false);
function closeModal() {
setOpen(false);
}
function openModal() {
setOpen(true);
}
const handleCheckout = async () => {
const { checkoutUrl } = await createCheckoutSession({ billedAnnually });
if (checkoutUrl) {
void router.push(checkoutUrl);
}
};
return (
<Transition appear show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex h-full min-h-full items-center justify-center text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="h-full w-full transform overflow-hidden bg-[#f9fafb] text-left align-middle shadow-xl transition-all">
<button
onClick={closeModal}
className="absolute right-6 top-6 text-gray-600"
tabIndex={0}
>
<CloseIcon />
</button>
<div className="mx-auto max-w-7xl px-8 pt-20">
<section className="relative mx-auto flex max-w-4xl flex-col justify-between gap-8 md:flex-row md:items-center ">
<div
id="heading-comp-1"
className="flex max-w-lg flex-col items-start"
>
<div className="my-8 ">
<h1 className="text-3xl font-bold leading-normal sm:text-4xl sm:leading-normal">
Upgrade your plan
</h1>
<p className="mt-4 pr-8 text-lg text-gray-500">
Turn your testimonials into your most powerful
marketing tool with Senja.
</p>
</div>
<p className="mt-6 text-base text-gray-500">
No contract. Cancel any time
</p>
</div>
<div className="w-full md:max-w-sm">
<div className="sm:align-center mb-4 flex flex-none flex-wrap items-center gap-4 px-2 py-1 md:justify-center">
<div className="relative flex items-center gap-2 text-sm font-medium">
<span className=" svelte-10wstod">Monthly</span>
<label className="group relative flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-600">
<input
onClick={() => setBilledAnnually(!billedAnnually)}
checked={billedAnnually}
type="checkbox"
className="peer absolute left-1/2 hidden h-full w-full -translate-x-1/2 rounded-md"
/>
<span className="flex h-6 w-12 flex-shrink-0 items-center rounded-full bg-gray-300 p-1 duration-300 ease-in-out after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow-md after:duration-300 group-hover:after:translate-x-1 peer-checked:bg-[#24b47e] peer-checked:after:translate-x-6">
<span className="sr-only" />
</span>
<span />
</label>
<span className="svelte-10wstod opacity-50">
Annually
</span>
<span className="rounded-md bg-[#cbf4c9] px-2 py-1 text-xs text-[#0e6245]">
20% Off
</span>
</div>
</div>
<div
id="pricing-comp"
className="w-full flex-none rounded-3xl border bg-white shadow-sm"
>
<div className="hero relative flex flex-col items-start rounded-3xl px-6 py-6 shadow-sm">
<div className="svelte-10wstod rounded-lg bg-white/20 px-2 font-medium">
Pro
</div>
<div className="mb-2 mt-4 flex items-end text-5xl font-extrabold tracking-tight">
{billedAnnually ? "$4" : "$5"}
<span className="mb-1 text-sm opacity-80">
/ mo.
</span>
</div>
<div className="mt-2 text-sm">
{billedAnnually
? "billed annually"
: "billed monthly"}
</div>
<div className="mt-2 flex-grow" />
<button
onClick={() => void handleCheckout()}
type="submit"
className="btn mt-4 block w-full appearance-none rounded-lg bg-black px-4 py-2.5 text-center text-sm font-medium text-white shadow-lg shadow-black/50 duration-100 focus:outline-transparent disabled:opacity-80"
>
Upgrade
</button>
</div>
<div className="mt-4 flex flex-col gap-2 pb-8">
{[
"Unlimited videos",
"Video uploads",
"Custom branding",
].map((x) => (
<div
key={x}
className="flex items-center gap-2 text-gray-500"
>
<div className="ml-6 h-5 w-5 flex-none">
<CheckIcon />
</div>
<div className="svelte-10wstod text-base text-gray-500 underline decoration-gray-400 decoration-dashed underline-offset-4">
{x}
</div>
</div>
))}
</div>
</div>
</div>
</section>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -30,7 +30,8 @@ const server = z.object({
AWS_BUCKET_NAME: z.string(),
STRIPE_SECRET_KEY: z.string(),
STRIPE_WEBHOOK_SECRET: z.string(),
STRIPE_PRICE_ID: z.string()
STRIPE_MONTHLY_PRICE_ID: z.string(),
STRIPE_ANNUAL_PRICE_ID: z.string()
});
/**
@ -66,7 +67,8 @@ const processEnv = {
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID
STRIPE_MONTHLY_PRICE_ID: process.env.STRIPE_MONTHLY_PRICE_ID,
STRIPE_ANNUAL_PRICE_ID: process.env.STRIPE_ANNUAL_PRICE_ID
};
// Don't touch the part below

View file

@ -14,10 +14,13 @@ import VideoUploadModal from "~/components/VideoUploadModal";
import { useAtom } from "jotai";
import uploadVideoModalOpen from "~/atoms/uploadVideoModalOpen";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import Paywall from "~/components/Paywall";
import paywallAtom from "~/atoms/paywallAtom";
const VideoList: NextPage = () => {
const [, setRecordOpen] = useAtom(uploadVideoModalOpen);
const [, setUploadOpen] = useAtom(recordVideoModalOpen);
const [, setPaywallOpen] = useAtom(paywallAtom);
const router = useRouter();
const { status } = useSession();
const { data: videos, isLoading } = api.video.getAll.useQuery();
@ -39,6 +42,7 @@ const VideoList: NextPage = () => {
<div className="flex flex-row items-center justify-center">
<VideoRecordModal />
<VideoUploadModal />
<Paywall />
<NewVideoMenu />
{status === "authenticated" && (
@ -67,7 +71,7 @@ const VideoList: NextPage = () => {
Record a video
</button>
<button
onClick={() => setRecordOpen(true)}
onClick={() => setPaywallOpen(true)}
className="ml-4 inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Upload a video

View file

@ -1,52 +1,57 @@
import { env } from "~/env.mjs";
import { getOrCreateStripeCustomerIdForUser } from "~/server/stripe-webhook-handlers";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { z } from "zod";
export const stripeRouter = createTRPCRouter({
createCheckoutSession: protectedProcedure.mutation(async ({ ctx }) => {
const { stripe, session, prisma, req } = ctx;
createCheckoutSession: protectedProcedure
.input(z.object({ billedAnnually: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const { stripe, session, prisma, req } = ctx;
const customerId = await getOrCreateStripeCustomerIdForUser({
prisma,
stripe,
userId: session.user?.id,
});
const customerId = await getOrCreateStripeCustomerIdForUser({
prisma,
stripe,
userId: session.user?.id,
});
if (!customerId) {
throw new Error("Could not create customer");
}
if (!customerId) {
throw new Error("Could not create customer");
}
const baseUrl =
env.NODE_ENV === "development"
? `http://${req.headers.host ?? "localhost:3000"}`
: `https://${req.headers.host ?? env.NEXTAUTH_URL}`;
const baseUrl =
env.NODE_ENV === "development"
? `http://${req.headers.host ?? "localhost:3000"}`
: `https://${req.headers.host ?? env.NEXTAUTH_URL}`;
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
client_reference_id: session.user?.id,
payment_method_types: ["card"],
mode: "subscription",
line_items: [
{
price: env.STRIPE_PRICE_ID,
quantity: 1,
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
client_reference_id: session.user?.id,
payment_method_types: ["card"],
mode: "subscription",
line_items: [
{
price: input.billedAnnually
? env.STRIPE_ANNUAL_PRICE_ID
: env.STRIPE_MONTHLY_PRICE_ID,
quantity: 1,
},
],
success_url: `${baseUrl}/videos?checkoutSuccess=true`,
cancel_url: `${baseUrl}/videos?checkoutCanceled=true`,
subscription_data: {
metadata: {
userId: session.user?.id,
},
},
],
success_url: `${baseUrl}/videos?checkoutSuccess=true`,
cancel_url: `${baseUrl}/videos?checkoutCanceled=true`,
subscription_data: {
metadata: {
userId: session.user?.id,
},
},
});
});
if (!checkoutSession) {
throw new Error("Could not create checkout session");
}
if (!checkoutSession) {
throw new Error("Could not create checkout session");
}
return { checkoutUrl: checkoutSession.url };
}),
return { checkoutUrl: checkoutSession.url };
}),
createBillingPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
const { stripe, session, prisma, req } = ctx;