Skip to content

Commit

Permalink
feat: implement stripe billing
Browse files Browse the repository at this point in the history
  • Loading branch information
shadcn committed Nov 21, 2022
1 parent 61df1be commit b30ac75
Show file tree
Hide file tree
Showing 31 changed files with 579 additions and 64 deletions.
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ SMTP_HOST=smtp.postmarkapp.com
SMTP_PORT=25
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=Taxonomy <taxonomy@example.com>
SMTP_FROM=Taxonomy <taxonomy@example.com>

# -----------------------------------------------------------------------------
# Subscriptions (Stripe)
# -----------------------------------------------------------------------------
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
SUBSCRIPTION_PLAN_PRICE_ID_PRO=
17 changes: 17 additions & 0 deletions app/(dashboard)/dashboard/billing/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardShell>
<DashboardHeader
heading="Billing"
text="Manage billing and your subscription plan."
/>
<div className="grid gap-10">
<Card.Skeleton />
</div>
</DashboardShell>
)
}
30 changes: 30 additions & 0 deletions app/(dashboard)/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardShell>
<DashboardHeader
heading="Billing"
text="Manage billing and your subscription plan."
/>
<div className="grid gap-10">
<BillingForm subscriptionPlan={subscriptionPlan} />
</div>
</DashboardShell>
)
}
85 changes: 85 additions & 0 deletions components/dashboard/billing-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement> {
subscriptionPlan: UserSubscriptionPlan
}

export function BillingForm({
subscriptionPlan,
className,
...props
}: BillingFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(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 (
<form className={cn(className)} onSubmit={onSubmit} {...props}>
<Card>
<Card.Header>
<Card.Title>Plan</Card.Title>
<Card.Description>
You are currently on the <strong>{subscriptionPlan.name}</strong>{" "}
plan.
</Card.Description>
</Card.Header>
<Card.Content>{subscriptionPlan.description}</Card.Content>
<Card.Footer className="flex items-center justify-between">
<button
type="submit"
className={cn(
"relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
{
"cursor-not-allowed opacity-60": isLoading,
}
)}
disabled={isLoading}
>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
{subscriptionPlan.isPro ? "Manage Subscription" : "Upgrade to PRO"}
</button>
{subscriptionPlan.isPro ? (
<p className="rounded-full text-xs font-medium">
{subscriptionPlan.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}.
</p>
) : null}
</Card.Footer>
</Card>
</form>
)
}
2 changes: 1 addition & 1 deletion components/dashboard/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Post, "id" | "title" | "content" | "published">
Expand Down
5 changes: 5 additions & 0 deletions components/dashboard/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 28 additions & 23 deletions components/dashboard/post-create-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<Post, "id">> {
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<HTMLButtonElement> {}
Expand All @@ -38,9 +18,34 @@ export function PostCreateButton({
const [isLoading, setIsLoading] = React.useState<boolean>(false)

async function onClick() {
setIsLoading(!isLoading)
setIsLoading(true)

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 createPost()
const post = await response.json()

// This forces a cache invalidation.
router.refresh()
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/post-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Expand Down
10 changes: 10 additions & 0 deletions components/dashboard/user-account-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,22 @@ export function UserAccountNav({ user }: UserAccountNavProps) {
Dashboard
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Link href="/dashboard/billing" className="w-full">
Billing
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Link href="/dashboard/settings" className="w-full">
Settings
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Link href="/docs" target="_blank" className="w-full">
Documentation
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Link
href={siteConfig.links.github}
Expand Down
4 changes: 2 additions & 2 deletions components/dashboard/user-auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { zodResolver } from "@hookform/resolvers/zod"

import { cn } from "@/lib/utils"
import { userAuthSchema } from "@/lib/validations/auth"
import toast from "@/ui/toast"
import { toast } from "@/ui/toast"
import { Icons } from "@/components/icons"

interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
Expand All @@ -25,7 +25,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
resolver: zodResolver(userAuthSchema),
})
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const searchParams = useSearchParams();
const searchParams = useSearchParams()

async function onSubmit(data: FormData) {
setIsLoading(true)
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/user-name-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement> {
user: Pick<User, "id" | "name">
Expand Down
2 changes: 1 addition & 1 deletion components/docs/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement> {}

Expand Down
2 changes: 2 additions & 0 deletions components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ChevronLeft,
ChevronRight,
Command,
CreditCard,
File,
FileText,
Github,
Expand Down Expand Up @@ -34,6 +35,7 @@ export const Icons = {
page: File,
media: Image,
settings: Settings,
billing: CreditCard,
ellipsis: MoreVertical,
add: Plus,
warning: AlertTriangle,
Expand Down
2 changes: 1 addition & 1 deletion config/docs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DocsConfig } from "types/config"
import { DocsConfig } from "types"

export const docsConfig: DocsConfig = {
mainNav: [
Expand Down
2 changes: 1 addition & 1 deletion config/marketing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MarketingConfig } from "types/config"
import { MarketingConfig } from "types"

export const marketingConfig: MarketingConfig = {
mainNav: [
Expand Down
2 changes: 1 addition & 1 deletion config/site.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SiteConfig } from "types/config"
import { SiteConfig } from "types"

export const siteConfig: SiteConfig = {
name: "Taxonomy",
Expand Down
14 changes: 14 additions & 0 deletions config/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -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,
}
5 changes: 5 additions & 0 deletions lib/exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class RequiresProPlanError extends Error {
constructor(message = "This action requires a pro plan") {
super(message)
}
}
6 changes: 6 additions & 0 deletions lib/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Stripe from "stripe"

export const stripe = new Stripe(process.env.STRIPE_API_KEY, {
apiVersion: "2022-11-15",
typescript: true,
})
Loading

0 comments on commit b30ac75

Please sign in to comment.