From b30ac75581ef7ef040f98e549e912e3eadeffa8e Mon Sep 17 00:00:00 2001 From: shadcn Date: Mon, 21 Nov 2022 12:40:10 +0400 Subject: [PATCH] feat: implement stripe billing --- .env.example | 9 +- app/(dashboard)/dashboard/billing/loading.tsx | 17 ++++ app/(dashboard)/dashboard/billing/page.tsx | 30 +++++++ components/dashboard/billing-form.tsx | 85 ++++++++++++++++++ components/dashboard/editor.tsx | 2 +- components/dashboard/nav.tsx | 5 ++ components/dashboard/post-create-button.tsx | 51 ++++++----- components/dashboard/post-operations.tsx | 2 +- components/dashboard/user-account-nav.tsx | 10 +++ components/dashboard/user-auth-form.tsx | 4 +- components/dashboard/user-name-form.tsx | 2 +- components/docs/search.tsx | 2 +- components/icons.tsx | 2 + config/docs.ts | 2 +- config/marketing.ts | 2 +- config/site.ts | 2 +- config/subscriptions.ts | 14 +++ lib/exceptions.ts | 5 ++ lib/stripe.ts | 6 ++ lib/subscription.ts | 44 ++++++++++ lib/utils.ts | 7 +- package.json | 4 +- pages/api/posts/index.ts | 25 +++++- pages/api/users/stripe.ts | 58 +++++++++++++ pages/api/webhooks/stripe.ts | 81 +++++++++++++++++ .../migration.sql | 27 ++++++ prisma/schema.prisma | 5 ++ types/config.d.ts | 18 ---- types/index.d.ts | 31 +++++++ ui/toast.tsx | 4 +- yarn.lock | 87 +++++++++++++++++-- 31 files changed, 579 insertions(+), 64 deletions(-) create mode 100644 app/(dashboard)/dashboard/billing/loading.tsx create mode 100644 app/(dashboard)/dashboard/billing/page.tsx create mode 100644 components/dashboard/billing-form.tsx create mode 100644 config/subscriptions.ts create mode 100644 lib/exceptions.ts create mode 100644 lib/stripe.ts create mode 100644 lib/subscription.ts create mode 100644 pages/api/users/stripe.ts create mode 100644 pages/api/webhooks/stripe.ts create mode 100644 prisma/migrations/20221118173244_add_stripe_columns/migration.sql delete mode 100644 types/config.d.ts diff --git a/.env.example b/.env.example index 8bc1dfe..b655857 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,11 @@ SMTP_HOST=smtp.postmarkapp.com SMTP_PORT=25 SMTP_USER= SMTP_PASSWORD= -SMTP_FROM=Taxonomy \ No newline at end of file +SMTP_FROM=Taxonomy + +# ----------------------------------------------------------------------------- +# Subscriptions (Stripe) +# ----------------------------------------------------------------------------- +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= +SUBSCRIPTION_PLAN_PRICE_ID_PRO= \ No newline at end of file diff --git a/app/(dashboard)/dashboard/billing/loading.tsx b/app/(dashboard)/dashboard/billing/loading.tsx new file mode 100644 index 0000000..fdda505 --- /dev/null +++ b/app/(dashboard)/dashboard/billing/loading.tsx @@ -0,0 +1,17 @@ +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" +import { Card } from "@/ui/card" + +export default function DashboardBillingLoading() { + return ( + + +
+ +
+
+ ) +} diff --git a/app/(dashboard)/dashboard/billing/page.tsx b/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 0000000..afd188b --- /dev/null +++ b/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,30 @@ +import { redirect } from "next/navigation" + +import { getCurrentUser } from "@/lib/session" +import { authOptions } from "@/lib/auth" +import { getUserSubscriptionPlan as getUserSubscriptionPlan } from "@/lib/subscription" +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" +import { BillingForm } from "@/components/dashboard/billing-form" + +export default async function BillingPage() { + const user = await getCurrentUser() + + if (!user) { + redirect(authOptions.pages.signIn) + } + + const subscriptionPlan = await getUserSubscriptionPlan(user.id) + + return ( + + +
+ +
+
+ ) +} diff --git a/components/dashboard/billing-form.tsx b/components/dashboard/billing-form.tsx new file mode 100644 index 0000000..5e85df9 --- /dev/null +++ b/components/dashboard/billing-form.tsx @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" + +import { UserSubscriptionPlan } from "types" +import { cn, formatDate } from "@/lib/utils" +import { Card } from "@/ui/card" +import { toast } from "@/ui/toast" +import { Icons } from "@/components/icons" + +interface BillingFormProps extends React.HTMLAttributes { + subscriptionPlan: UserSubscriptionPlan +} + +export function BillingForm({ + subscriptionPlan, + className, + ...props +}: BillingFormProps) { + const [isLoading, setIsLoading] = React.useState(false) + + async function onSubmit(event) { + event.preventDefault() + setIsLoading(!isLoading) + + // Get a Stripe session URL. + const response = await fetch("/api/users/stripe") + + if (!response?.ok) { + return toast({ + title: "Something went wrong.", + message: "Please refresh the page and try again.", + type: "error", + }) + } + + // Redirect to the Stripe session. + // This could be a checkout page for initial upgrade. + // Or portal to manage existing subscription. + const session = await response.json() + if (session) { + window.location.href = session.url + } + } + + return ( +
+ + + Plan + + You are currently on the {subscriptionPlan.name}{" "} + plan. + + + {subscriptionPlan.description} + + + {subscriptionPlan.isPro ? ( +

+ {subscriptionPlan.isCanceled + ? "Your plan will be canceled on " + : "Your plan renews on "} + {formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}. +

+ ) : null} +
+
+
+ ) +} diff --git a/components/dashboard/editor.tsx b/components/dashboard/editor.tsx index a985031..baa04c4 100644 --- a/components/dashboard/editor.tsx +++ b/components/dashboard/editor.tsx @@ -12,7 +12,7 @@ import { useRouter } from "next/navigation" import { Icons } from "@/components/icons" import { postPatchSchema } from "@/lib/validations/post" -import toast from "@/ui/toast" +import { toast } from "@/ui/toast" interface EditorProps { post: Pick diff --git a/components/dashboard/nav.tsx b/components/dashboard/nav.tsx index 3718af5..b7ae424 100644 --- a/components/dashboard/nav.tsx +++ b/components/dashboard/nav.tsx @@ -25,6 +25,11 @@ export const navigationItems: NavItem[] = [ icon: Icons.media, disabled: true, }, + { + title: "Billing", + href: "/dashboard/billing", + icon: Icons.billing, + }, { title: "Settings", href: "/dashboard/settings", diff --git a/components/dashboard/post-create-button.tsx b/components/dashboard/post-create-button.tsx index 474174e..ce8bfd1 100644 --- a/components/dashboard/post-create-button.tsx +++ b/components/dashboard/post-create-button.tsx @@ -2,30 +2,10 @@ import * as React from "react" import { useRouter } from "next/navigation" -import { Post } from "@prisma/client" import { cn } from "@/lib/utils" import { Icons } from "@/components/icons" -import toast from "@/ui/toast" - -async function createPost(): Promise> { - const response = await fetch("/api/posts", { - method: "POST", - body: JSON.stringify({ - title: "Untitled Post", - }), - }) - - if (!response?.ok) { - toast({ - title: "Something went wrong.", - message: "Your post was not created. Please try again.", - type: "error", - }) - } - - return await response.json() -} +import { toast } from "@/ui/toast" interface PostCreateButtonProps extends React.HTMLAttributes {} @@ -38,9 +18,34 @@ export function PostCreateButton({ const [isLoading, setIsLoading] = React.useState(false) async function onClick() { - setIsLoading(!isLoading) + setIsLoading(true) - const post = await createPost() + const response = await fetch("/api/posts", { + method: "POST", + body: JSON.stringify({ + title: "Untitled Post", + }), + }) + + setIsLoading(false) + + if (!response?.ok) { + if (response.status === 402) { + return toast({ + title: "Limit of 3 posts reached.", + message: "Please upgrade to the PRO plan.", + type: "error", + }) + } + + return toast({ + title: "Something went wrong.", + message: "Your post was not created. Please try again.", + type: "error", + }) + } + + const post = await response.json() // This forces a cache invalidation. router.refresh() diff --git a/components/dashboard/post-operations.tsx b/components/dashboard/post-operations.tsx index 884411a..03bcd68 100644 --- a/components/dashboard/post-operations.tsx +++ b/components/dashboard/post-operations.tsx @@ -8,7 +8,7 @@ import { Post } from "@prisma/client" import { DropdownMenu } from "@/ui/dropdown" import { Icons } from "@/components/icons" import { Alert } from "@/ui/alert" -import toast from "@/ui/toast" +import { toast } from "@/ui/toast" async function deletePost(postId: string) { const response = await fetch(`/api/posts/${postId}`, { diff --git a/components/dashboard/user-account-nav.tsx b/components/dashboard/user-account-nav.tsx index 18ada1c..58d36d1 100644 --- a/components/dashboard/user-account-nav.tsx +++ b/components/dashboard/user-account-nav.tsx @@ -36,12 +36,22 @@ export function UserAccountNav({ user }: UserAccountNavProps) { Dashboard + + + Billing + + Settings + + + Documentation + + {} @@ -25,7 +25,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { resolver: zodResolver(userAuthSchema), }) const [isLoading, setIsLoading] = React.useState(false) - const searchParams = useSearchParams(); + const searchParams = useSearchParams() async function onSubmit(data: FormData) { setIsLoading(true) diff --git a/components/dashboard/user-name-form.tsx b/components/dashboard/user-name-form.tsx index 540d1e0..f0af425 100644 --- a/components/dashboard/user-name-form.tsx +++ b/components/dashboard/user-name-form.tsx @@ -10,8 +10,8 @@ import { useRouter } from "next/navigation" import { cn } from "@/lib/utils" import { userNameSchema } from "@/lib/validations/user" import { Card } from "@/ui/card" +import { toast } from "@/ui/toast" import { Icons } from "@/components/icons" -import toast from "@/ui/toast" interface UserNameFormProps extends React.HTMLAttributes { user: Pick diff --git a/components/docs/search.tsx b/components/docs/search.tsx index 11447ff..cba5aff 100644 --- a/components/docs/search.tsx +++ b/components/docs/search.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { cn } from "@/lib/utils" -import toast from "@/ui/toast" +import { toast } from "@/ui/toast" interface DocsSearchProps extends React.HTMLAttributes {} diff --git a/components/icons.tsx b/components/icons.tsx index 180abe1..cc09277 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight, Command, + CreditCard, File, FileText, Github, @@ -34,6 +35,7 @@ export const Icons = { page: File, media: Image, settings: Settings, + billing: CreditCard, ellipsis: MoreVertical, add: Plus, warning: AlertTriangle, diff --git a/config/docs.ts b/config/docs.ts index fd9ef32..14c9047 100644 --- a/config/docs.ts +++ b/config/docs.ts @@ -1,4 +1,4 @@ -import { DocsConfig } from "types/config" +import { DocsConfig } from "types" export const docsConfig: DocsConfig = { mainNav: [ diff --git a/config/marketing.ts b/config/marketing.ts index f617334..e58530d 100644 --- a/config/marketing.ts +++ b/config/marketing.ts @@ -1,4 +1,4 @@ -import { MarketingConfig } from "types/config" +import { MarketingConfig } from "types" export const marketingConfig: MarketingConfig = { mainNav: [ diff --git a/config/site.ts b/config/site.ts index 3925307..2f4b12f 100644 --- a/config/site.ts +++ b/config/site.ts @@ -1,4 +1,4 @@ -import { SiteConfig } from "types/config" +import { SiteConfig } from "types" export const siteConfig: SiteConfig = { name: "Taxonomy", diff --git a/config/subscriptions.ts b/config/subscriptions.ts new file mode 100644 index 0000000..2ed016a --- /dev/null +++ b/config/subscriptions.ts @@ -0,0 +1,14 @@ +import { SubscriptionPlan } from "types" + +export const freePlan: SubscriptionPlan = { + name: "Free", + description: + "The free plan is limited to 3 posts. Upgrade to the PRO plan for unlimited posts.", + stripePriceId: null, +} + +export const proPlan: SubscriptionPlan = { + name: "PRO", + description: "The PRO plan has unlimited posts.", + stripePriceId: process.env.SUBSCRIPTION_PLAN_PRICE_ID_PRO, +} diff --git a/lib/exceptions.ts b/lib/exceptions.ts new file mode 100644 index 0000000..b07ce62 --- /dev/null +++ b/lib/exceptions.ts @@ -0,0 +1,5 @@ +export class RequiresProPlanError extends Error { + constructor(message = "This action requires a pro plan") { + super(message) + } +} diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..f404264 --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe" + +export const stripe = new Stripe(process.env.STRIPE_API_KEY, { + apiVersion: "2022-11-15", + typescript: true, +}) diff --git a/lib/subscription.ts b/lib/subscription.ts new file mode 100644 index 0000000..4561bf8 --- /dev/null +++ b/lib/subscription.ts @@ -0,0 +1,44 @@ +import { UserSubscriptionPlan } from "types" +import { freePlan, proPlan } from "@/config/subscriptions" +import { db } from "@/lib/db" +import { stripe } from "@/lib/stripe" + +export async function getUserSubscriptionPlan( + userId: string +): Promise { + const user = await db.user.findFirst({ + where: { + id: userId, + }, + select: { + stripeSubscriptionId: true, + stripeCurrentPeriodEnd: true, + stripeCustomerId: true, + stripePriceId: true, + }, + }) + + // Check if user is on a pro plan. + const isPro = + user.stripePriceId && + user.stripeCurrentPeriodEnd?.getTime() + 86_400_000 > Date.now() + + const plan = isPro ? proPlan : freePlan + + // If user has a pro plan, check cancel status on Stripe. + let isCanceled = false + if (isPro && user.stripeSubscriptionId) { + const stripePlan = await stripe.subscriptions.retrieve( + user.stripeSubscriptionId + ) + isCanceled = stripePlan.cancel_at_period_end + } + + return { + ...plan, + ...user, + stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd?.getTime(), + isCanceled, + isPro, + } +} diff --git a/lib/utils.ts b/lib/utils.ts index 513abb9..13355eb 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -5,7 +5,7 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function formatDate(input: string): string { +export function formatDate(input: string | number): string { const date = new Date(input) return date.toLocaleDateString("en-US", { month: "long", @@ -13,3 +13,8 @@ export function formatDate(input: string): string { year: "numeric", }) } + +// Since we're already setting NEXTAUTH_URL, we can use same to create absolute URLs. +export function absoluteUrl(path: string) { + return `${process.env.NEXTAUTH_URL}${path}` +} diff --git a/package.json b/package.json index 7a3501b..a1d5681 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "nodemailer": "^6.8.0", "postmark": "^3.0.14", "prop-types": "^15.8.1", + "raw-body": "^2.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-editor-js": "^2.1.0", @@ -53,13 +54,14 @@ "react-textarea-autosize": "^8.3.4", "sharp": "^0.31.1", "shiki": "^0.11.1", + "stripe": "^11.1.0", "tailwind-merge": "^1.6.2", "tailwindcss-animate": "^1.0.5", "zod": "^3.19.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.7", - "@types/node": "18.6.4", + "@types/node": "^18.11.9", "@types/react": "18.0.15", "@types/react-dom": "18.0.6", "autoprefixer": "^10.4.8", diff --git a/pages/api/posts/index.ts b/pages/api/posts/index.ts index a349636..65bf97d 100644 --- a/pages/api/posts/index.ts +++ b/pages/api/posts/index.ts @@ -5,6 +5,8 @@ import * as z from "zod" import { db } from "@/lib/db" import { withMethods } from "@/lib/api-middlewares/with-methods" import { withAuthentication } from "@/lib/api-middlewares/with-authentication" +import { getUserSubscriptionPlan } from "@/lib/subscription" +import { RequiresProPlanError } from "@/lib/exceptions" const postCreateSchema = z.object({ title: z.string().optional(), @@ -13,6 +15,7 @@ const postCreateSchema = z.object({ async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }) + const user = session?.user if (req.method === "GET") { try { @@ -24,7 +27,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { createdAt: true, }, where: { - authorId: session.user.id, + authorId: user.id, }, }) @@ -36,6 +39,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { try { + const subscriptionPlan = await getUserSubscriptionPlan(user.id) + + // If user is on a free plan. + // Check if user has reached limit of 3 posts. + if (!subscriptionPlan?.isPro) { + const count = await db.post.count({ + where: { + authorId: user.id, + }, + }) + + if (count >= 3) { + throw new RequiresProPlanError() + } + } + const body = postCreateSchema.parse(JSON.parse(req.body)) const post = await db.post.create({ @@ -55,6 +74,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(422).json(error.issues) } + if (error instanceof RequiresProPlanError) { + return res.status(402).end() + } + return res.status(500).end() } } diff --git a/pages/api/users/stripe.ts b/pages/api/users/stripe.ts new file mode 100644 index 0000000..c2d2d60 --- /dev/null +++ b/pages/api/users/stripe.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { getSession } from "next-auth/react" + +import { proPlan } from "@/config/subscriptions" +import { withMethods } from "@/lib/api-middlewares/with-methods" +import { getUserSubscriptionPlan } from "@/lib/subscription" +import { stripe } from "@/lib/stripe" +import { withAuthentication } from "@/lib/api-middlewares/with-authentication" +import { absoluteUrl } from "@/lib/utils" + +const billingUrl = absoluteUrl("/dashboard/billing") + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") { + try { + const session = await getSession({ req }) + const user = session.user + const subscriptionPlan = await getUserSubscriptionPlan(user.id) + + // The user is on the pro plan. + // Create a portal session to manage subscription. + if (subscriptionPlan.isPro) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: subscriptionPlan.stripeCustomerId, + return_url: billingUrl, + }) + + return res.json({ url: stripeSession.url }) + } + + // The user is on the free plan. + // Create a checkout session to upgrade. + const stripeSession = await stripe.checkout.sessions.create({ + success_url: billingUrl, + cancel_url: billingUrl, + payment_method_types: ["card"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: user.email, + line_items: [ + { + price: proPlan.stripePriceId, + quantity: 1, + }, + ], + metadata: { + userId: user.id, + }, + }) + + return res.json({ url: stripeSession.url }) + } catch (error) { + return res.status(500).end() + } + } +} + +export default withMethods(["GET"], withAuthentication(handler)) diff --git a/pages/api/webhooks/stripe.ts b/pages/api/webhooks/stripe.ts new file mode 100644 index 0000000..6da0c2c --- /dev/null +++ b/pages/api/webhooks/stripe.ts @@ -0,0 +1,81 @@ +import { NextApiRequest, NextApiResponse } from "next" +import Stripe from "stripe" +import rawBody from "raw-body" + +import { stripe } from "@/lib/stripe" +import { db } from "@/lib/db" + +export const config = { + api: { + // Turn off the body parser so we can access raw body for verification. + bodyParser: false, + }, +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const body = await rawBody(req) + const signature = req.headers["stripe-signature"] + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET + ) + } catch (error) { + return res.status(400).send(`Webhook Error: ${error.message}`) + } + + const session = event.data.object as Stripe.Checkout.Session + + if (event.type === "checkout.session.completed") { + // Retrieve the subscription details from Stripe. + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + // Update the user stripe into in our database. + // Since this is the initial subscription, we need to update + // the subscription id and customer id. + await db.user.update({ + where: { + id: session.metadata.userId, + }, + data: { + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }) + } + + if (event.type === "invoice.payment_succeeded") { + // Retrieve the subscription details from Stripe. + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + // Update the price id and set the new period end. + await db.user.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }) + } + + return res.json({}) +} diff --git a/prisma/migrations/20221118173244_add_stripe_columns/migration.sql b/prisma/migrations/20221118173244_add_stripe_columns/migration.sql new file mode 100644 index 0000000..d227d68 --- /dev/null +++ b/prisma/migrations/20221118173244_add_stripe_columns/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - A unique constraint covering the columns `[stripe_customer_id]` on the table `users` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[stripe_subscription_id]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_userId_fkey`; + +-- DropForeignKey +ALTER TABLE `posts` DROP FOREIGN KEY `posts_authorId_fkey`; + +-- DropForeignKey +ALTER TABLE `sessions` DROP FOREIGN KEY `sessions_userId_fkey`; + +-- AlterTable +ALTER TABLE `users` ADD COLUMN `stripe_current_period_end` DATETIME(3) NULL, + ADD COLUMN `stripe_customer_id` VARCHAR(191) NULL, + ADD COLUMN `stripe_price_id` VARCHAR(191) NULL, + ADD COLUMN `stripe_subscription_id` VARCHAR(191) NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `users_stripe_customer_id_key` ON `users`(`stripe_customer_id`); + +-- CreateIndex +CREATE UNIQUE INDEX `users_stripe_subscription_id_key` ON `users`(`stripe_subscription_id`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a68a4b..485cd6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,11 @@ model User { sessions Session[] Post Post[] + stripeCustomerId String? @unique @map(name: "stripe_customer_id") + stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") + stripePriceId String? @map(name: "stripe_price_id") + stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end") + @@map(name: "users") } diff --git a/types/config.d.ts b/types/config.d.ts deleted file mode 100644 index 1079d8f..0000000 --- a/types/config.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MainNavItem, SidebarNavItem } from "types" - -export type SiteConfig = { - name: string - links: { - twitter: string - github: string - } -} - -export type DocsConfig = { - mainNav: MainNavItem[] - sidebarNav: SidebarNavItem[] -} - -export type MarketingConfig = { - mainNav: MainNavItem[] -} diff --git a/types/index.d.ts b/types/index.d.ts index 7178e92..01080e0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,3 +1,4 @@ +import { User } from "@prisma/client" import type { Icon } from "lucide-react" export type NavItem = { @@ -23,3 +24,33 @@ export type SidebarNavItem = { items: NavLink[] } ) + +export type SiteConfig = { + name: string + links: { + twitter: string + github: string + } +} + +export type DocsConfig = { + mainNav: MainNavItem[] + sidebarNav: SidebarNavItem[] +} + +export type MarketingConfig = { + mainNav: MainNavItem[] +} + +export type SubscriptionPlan = { + name: string + description: string + stripePriceId: string +} + +export type UserSubscriptionPlan = SubscriptionPlan & + Pick & { + stripeCurrentPeriodEnd: number + isPro: boolean + isCanceled: boolean + } diff --git a/ui/toast.tsx b/ui/toast.tsx index c63b90c..8043fc3 100644 --- a/ui/toast.tsx +++ b/ui/toast.tsx @@ -66,11 +66,9 @@ interface ToastOpts { duration?: number } -export default function toast(opts: ToastOpts) { +export function toast(opts: ToastOpts) { const { title, message, type = "default", duration = 3000 } = opts - // alert(message) - return hotToast.custom( ({ visible }) => ( =12.12.47", "@types/node@>=13.7.0": +"@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0", "@types/node@^18.11.9": version "18.11.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== @@ -1417,6 +1412,11 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -1704,6 +1704,11 @@ defined@^1.0.0: resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf" integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + dequal@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -2675,6 +2680,24 @@ html-void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" integrity sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A== +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -2716,7 +2739,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4252,6 +4275,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qs@^6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -4262,6 +4292,16 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +raw-body@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -4603,6 +4643,11 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" @@ -4630,6 +4675,11 @@ semver@^7.3.5, semver@^7.3.7: dependencies: lru-cache "^6.0.0" +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + sharp@^0.31.1: version "0.31.1" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.31.1.tgz#b2f7076d381a120761aa93700cadefcf90a22458" @@ -4733,6 +4783,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -4816,6 +4871,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +stripe@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-11.1.0.tgz#28931a1136ccc2dffa44ef27027275a7b346a266" + integrity sha512-erOslPQZSYKOotQjmKRy4eBon/tdhzLIYzBdPSNVWDdatSQozkkPlh8mVeXNwubYYZYx61/yS23eWiGDF93z2w== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.11.0" + style-to-object@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46" @@ -4914,6 +4977,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + toml@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" @@ -5103,6 +5171,11 @@ unist-util-visit@^4.0.0, unist-util-visit@^4.1.1: unist-util-is "^5.0.0" unist-util-visit-parents "^5.1.1" +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + update-browserslist-db@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3"