add paywall
This commit is contained in:
parent
ff164bfe8e
commit
200c9c2612
5 changed files with 221 additions and 40 deletions
3
src/atoms/paywallAtom.ts
Normal file
3
src/atoms/paywallAtom.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export default atom<boolean>(false);
|
||||
167
src/components/Paywall.tsx
Normal file
167
src/components/Paywall.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue