Skip to content

Commit

Permalink
feat: update billing
Browse files Browse the repository at this point in the history
  • Loading branch information
sadmann7 committed Mar 9, 2024
1 parent 11e4d0b commit 23f0ea8
Show file tree
Hide file tree
Showing 15 changed files with 461 additions and 53 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
Expand Down
57 changes: 57 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,42 @@ import { Skeleton } from "@/components/ui/skeleton"
export function BillingSkeleton() {
return (
<>
<Card className="space-y-3 p-6">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-4 w-1/2" />
<Card>
<CardHeader className="space-y-2.5">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="grid gap-6 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2">
<div className="flex items-center space-x-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="size-5" />
</div>
<Skeleton className="h-4 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
))}
</CardContent>
</Card>
<section className="grid gap-6 lg:grid-cols-2">
<section className="grid gap-6 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card
key={i}
className={cn("flex flex-col", {
"sm:col-span-2 lg:col-span-1": i === 2,
})}
>
<CardHeader className="h-full space-y-2.5">
<Skeleton className="h-7 w-24" />
<CardHeader className="flex-1 space-y-2.5">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="grid h-full flex-1 place-items-start gap-6">
<Skeleton className="h-8 w-40" />
<CardContent className="grid flex-1 place-items-start gap-6">
<Skeleton className="h-7 w-40" />
<div className="w-full space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-start gap-2">
Expand Down
78 changes: 57 additions & 21 deletions src/app/(dashboard)/dashboard/billing/_components/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import Link from "next/link"
import type { SubscriptionPlanWithPrice, UserSubscriptionPlan } from "@/types"
import { CheckIcon } from "@radix-ui/react-icons"

import { getPlanLimits } from "@/lib/subscription"
import { cn, formatDate } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
Expand All @@ -14,37 +16,75 @@ import {
} from "@/components/ui/card"

import { ManageSubscriptionForm } from "./manage-subscription-form"
import { UsageCard } from "./usage-card"

interface BillingProps {
subscriptionPlanPromise: Promise<UserSubscriptionPlan | null>
subscriptionPlansPromise: Promise<SubscriptionPlanWithPrice[]>
usagePromise: Promise<{
storeCount: number
productCount: number
}>
}

export async function Billing({
subscriptionPlanPromise,
subscriptionPlansPromise,
usagePromise,
}: BillingProps) {
const [subscriptionPlan, subscriptionPlans] = await Promise.all([
const [subscriptionPlan, subscriptionPlans, usage] = await Promise.all([
subscriptionPlanPromise,
subscriptionPlansPromise,
usagePromise,
])

const { storeLimit, productLimit } = getPlanLimits({
planTitle: subscriptionPlan?.title ?? "free",
})

const storeProgress = Math.floor((usage.storeCount / storeLimit) * 100)
const productProgress = Math.floor((usage.productCount / productLimit) * 100)

return (
<>
<Card className="space-y-2.5 p-6">
<CardTitle className="text-2xl capitalize">
{subscriptionPlan?.title ?? "Free"}
</CardTitle>
<CardDescription>
{!subscriptionPlan?.isSubscribed
? "Upgrade to unlock more features."
: subscriptionPlan.isCanceled
<Card>
<CardHeader>
<CardTitle className="text-lg capitalize">Plan and Usage</CardTitle>
<div className="text-sm text-muted-foreground">
You&apos;re currently on the{" "}
<Badge
variant="secondary"
className="pointer-events-none capitalize text-foreground/90"
>
{subscriptionPlan?.title}
</Badge>{" "}
plan.{" "}
{subscriptionPlan?.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{subscriptionPlan?.stripeCurrentPeriodEnd
? `${formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}.`
: null}
</CardDescription>
{subscriptionPlan?.stripeCurrentPeriodEnd ? (
<span className="font-medium text-foreground/90">
{formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}.
</span>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-6 sm:grid-cols-2">
<UsageCard
title="Stores"
usage={usage.storeCount}
limit={storeLimit}
progress={storeProgress}
moreInfo="The number of stores you can create on the current plan."
/>
<UsageCard
title="Products"
usage={usage.productCount}
limit={productLimit}
progress={productProgress}
moreInfo="The number of products you can create on the current plan."
/>
</CardContent>
</Card>
<section className="grid gap-6 lg:grid-cols-3">
{subscriptionPlans.map((plan, i) => (
Expand All @@ -54,15 +94,11 @@ export async function Billing({
"sm:col-span-2 lg:col-span-1": i === subscriptionPlans.length - 1,
})}
>
<CardHeader className="h-full">
<CardTitle className="line-clamp-1 text-2xl capitalize">
{plan.title}
</CardTitle>
<CardDescription className="line-clamp-2">
{plan.description}
</CardDescription>
<CardHeader className="flex-1">
<CardTitle className="text-lg capitalize">{plan.title}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="grid h-full flex-1 place-items-start gap-6">
<CardContent className="grid flex-1 place-items-start gap-6">
<div className="text-3xl font-bold">
{plan.price}
<span className="text-sm font-normal text-muted-foreground">
Expand Down
61 changes: 61 additions & 0 deletions src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"

import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import { Progress } from "@/components/ui/progress"

interface UsageCardProps {
title: string
usage: number
limit: number
progress: number
moreInfo: string
}

export function UsageCard({
title,
limit,
progress,
usage,
moreInfo,
}: UsageCardProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center space-x-2">
<CardTitle>{title}</CardTitle>
<HoverCard>
<HoverCardTrigger asChild>
<Button variant="ghost" size="icon" className="size-4">
<QuestionMarkCircledIcon
className="size-full"
aria-hidden="true"
/>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80" sideOffset={8}>
<p className="text-sm">{moreInfo}</p>
</HoverCardContent>
</HoverCard>
</div>
<CardDescription>
{usage} / {limit} stores ({progress}%)
</CardDescription>
</CardHeader>
<CardContent>
<Progress className="w-full" value={progress} max={100} />
</CardContent>
</Card>
)
}
24 changes: 23 additions & 1 deletion src/app/(dashboard)/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import * as React from "react"
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { env } from "@/env.js"
import { RocketIcon } from "@radix-ui/react-icons"

import { getCacheduser } from "@/lib/actions/auth"
import { getUsage } from "@/lib/actions/store"
import { getSubscriptionPlan, getSubscriptionPlans } from "@/lib/actions/stripe"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
PageHeader,
PageHeaderDescription,
Expand All @@ -30,19 +33,38 @@ export default async function BillingPage() {

const subscriptionPlanPromise = getSubscriptionPlan({ userId: user.id })
const subscriptionPlansPromise = getSubscriptionPlans()
const usagePromise = getUsage({ userId: user.id })

return (
<Shell variant="sidebar" as="div">
<Shell variant="sidebar">
<PageHeader>
<PageHeaderHeading size="sm">Billing</PageHeaderHeading>
<PageHeaderDescription size="sm">
Manage your billing and subscription
</PageHeaderDescription>
</PageHeader>
<Alert>
<RocketIcon className="size-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
Skateshop is a demo app using a Stripe test environment. You can find
a list of test card numbers on the{" "}
<a
href="https://stripe.com/docs/testing"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4 transition-colors hover:text-foreground/90"
>
Stripe docs
</a>
.
</AlertDescription>
</Alert>
<React.Suspense fallback={<BillingSkeleton />}>
<Billing
subscriptionPlanPromise={subscriptionPlanPromise}
subscriptionPlansPromise={subscriptionPlansPromise}
usagePromise={usagePromise}
/>
</React.Suspense>
</Shell>
Expand Down
Loading

0 comments on commit 23f0ea8

Please sign in to comment.