Skip to content

Commit

Permalink
feat: add price (#25)
Browse files Browse the repository at this point in the history
* feat: add price

* chore: update skeletons
  • Loading branch information
sadmann7 authored Feb 13, 2024
1 parent d2d18eb commit 0d955dd
Show file tree
Hide file tree
Showing 12 changed files with 310 additions and 127 deletions.
4 changes: 2 additions & 2 deletions src/app/(landing)/_components/feature-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ const ReactEmail = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
>
<g clipPath="url(#clip0_27_291)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M24.4558 24.4853C25.2339 23.7073 25.3805 22.6549 25.2947 21.746C25.2078 20.8254 24.8697 19.8258 24.3896 18.8287C23.957 17.9302 23.3802 16.9745 22.6821 16C23.3802 15.0255 23.957 14.0698 24.3896 13.1713C24.8697 12.1742 25.2078 11.1746 25.2947 10.254C25.3805 9.34508 25.2339 8.29273 24.4558 7.51472C23.6778 6.73671 22.6255 6.59004 21.7165 6.67584C20.796 6.76273 19.7964 7.10086 18.7993 7.58094C17.9007 8.01357 16.945 8.59036 15.9706 9.28842C14.9961 8.59036 14.0404 8.01357 13.1418 7.58094C12.1447 7.10086 11.1451 6.76273 10.2246 6.67584C9.31564 6.59004 8.26329 6.73671 7.48528 7.51472C6.70727 8.29273 6.5606 9.34508 6.6464 10.254C6.7333 11.1746 7.07142 12.1742 7.5515 13.1713C7.98414 14.0698 8.56092 15.0255 9.25898 16C8.56092 16.9745 7.98414 17.9302 7.5515 18.8287C7.07142 19.8258 6.7333 20.8254 6.6464 21.746C6.5606 22.6549 6.70727 23.7073 7.48528 24.4853C8.26329 25.2633 9.31564 25.41 10.2246 25.3242C11.1451 25.2373 12.1447 24.8991 13.1418 24.4191C14.0404 23.9864 14.9961 23.4096 15.9706 22.7116C16.945 23.4096 17.9007 23.9864 18.7993 24.4191C19.7964 24.8991 20.796 25.2373 21.7165 25.3242C22.6255 25.41 23.6778 25.2633 24.4558 24.4853ZM15.9706 20.948C16.8399 20.2684 17.724 19.4874 18.591 18.6205C19.458 17.7535 20.239 16.8693 20.9186 16C20.239 15.1307 19.458 14.2465 18.591 13.3795C17.724 12.5126 16.8399 11.7316 15.9706 11.052C15.1012 11.7316 14.2171 12.5126 13.3501 13.3795C12.4831 14.2465 11.7021 15.1307 11.0225 16C11.7021 16.8693 12.4831 17.7535 13.3501 18.6205C14.2171 19.4874 15.1012 20.2684 15.9706 20.948ZM17.1498 21.8145C17.968 21.1558 18.7885 20.4195 19.5893 19.6187C20.39 18.818 21.1264 17.9974 21.7851 17.1792C23.7187 19.9919 24.4627 22.4819 23.4576 23.487C22.4524 24.4922 19.9625 23.7482 17.1498 21.8145ZM10.156 17.1792C10.8148 17.9974 11.5511 18.818 12.3518 19.6187C13.1526 20.4195 13.9731 21.1558 14.7914 21.8145C11.9786 23.7482 9.48871 24.4922 8.48355 23.487C7.47839 22.4819 8.22238 19.9919 10.156 17.1792ZM10.156 14.8208C10.8148 14.0026 11.5511 13.182 12.3518 12.3813C13.1526 11.5805 13.9731 10.8442 14.7914 10.1855C11.9786 8.25182 9.48871 7.50783 8.48355 8.51299C7.47839 9.51815 8.22238 12.0081 10.156 14.8208ZM17.1498 10.1855C17.968 10.8442 18.7885 11.5805 19.5893 12.3813C20.39 13.182 21.1264 14.0026 21.7851 14.8208C23.7187 12.0081 24.4627 9.51815 23.4576 8.51299C22.4524 7.50783 19.9625 8.25182 17.1498 10.1855Z"
fill="currentColor"
stroke="currentColor"
Expand Down
28 changes: 28 additions & 0 deletions src/app/(main)/dashboard/_components/post-card-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

export function PostCardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-7 w-24" />
<Skeleton className="h-3 w-36" />
</CardHeader>
<CardContent className="line-clamp-3 text-sm">
<Skeleton className="h-5 w-24" />
</CardContent>
<CardFooter className="justify-between gap-2">
<Skeleton className="h-6 w-16" />
<div className="flex items-center space-x-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-12" />
</div>
</CardFooter>
</Card>
);
}
11 changes: 11 additions & 0 deletions src/app/(main)/dashboard/_components/posts-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PostCardSkeleton } from "./post-card-skeleton";

export function PostsSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
15 changes: 11 additions & 4 deletions src/app/(main)/dashboard/_components/posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import { NewPost } from "./new-post";
import { PostCard } from "./post-card";

interface PostsProps {
posts: RouterOutputs["post"]["myPosts"];
subscriptionPlan: RouterOutputs["stripe"]["getSubscriptionPlan"];
promises: Promise<
[RouterOutputs["post"]["myPosts"], RouterOutputs["stripe"]["getPlan"]]
>;
}

export const Posts = ({ posts, subscriptionPlan }: PostsProps) => {
export function Posts({ promises }: PostsProps) {
/**
* use is a React Hook that lets you read the value of a resource like a Promise or context.
* @see https://react.dev/reference/react/use
*/
const [posts, subscriptionPlan] = React.use(promises);

/**
* useOptimistic is a React Hook that lets you show a different state while an async action is underway.
* It accepts some state as an argument and returns a copy of that state that can be different during the duration of an async action such as a network request.
Expand Down Expand Up @@ -56,4 +63,4 @@ export const Posts = ({ posts, subscriptionPlan }: PostsProps) => {
))}
</div>
);
};
}
44 changes: 44 additions & 0 deletions src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

export function BillingSkeleton() {
return (
<>
<section>
<Card className="space-y-2 p-8">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-5 w-36" />
</Card>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i} className="flex flex-col p-2">
<CardHeader className="h-full">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-4 w-36" />
</CardHeader>
<CardContent className="h-full flex-1 space-y-6">
<Skeleton className="h-8 w-24" />
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
<CardFooter className="pt-4">
<Skeleton className="h-10 w-full" />
</CardFooter>
</Card>
))}
</section>
</>
);
}
97 changes: 97 additions & 0 deletions src/app/(main)/dashboard/billing/_components/billing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Link from "next/link";

import { CheckIcon } from "@/components/icons";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { formatDate } from "@/lib/utils";
import { type RouterOutputs } from "@/trpc/shared";
import { ManageSubscriptionForm } from "./manage-subscription-form";

interface BillingProps {
stripePromises: Promise<
[RouterOutputs["stripe"]["getPlans"], RouterOutputs["stripe"]["getPlan"]]
>;
}

export async function Billing({ stripePromises }: BillingProps) {
const [plans, plan] = await stripePromises;

return (
<>
<section>
<Card className="space-y-2 p-8">
<h3 className="text-lg font-semibold sm:text-xl">
{plan?.name ?? "Free"} plan
</h3>
<p className="text-sm text-muted-foreground">
{!plan?.isPro
? "The free plan is limited to 3 posts. Upgrade to the Pro plan to unlock unlimited posts."
: plan.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{plan?.stripeCurrentPeriodEnd
? formatDate(plan.stripeCurrentPeriodEnd)
: null}
</p>
</Card>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{plans.map((item) => (
<Card key={item.name} className="flex flex-col p-2">
<CardHeader className="h-full">
<CardTitle className="line-clamp-1">{item.name}</CardTitle>
<CardDescription className="line-clamp-2">
{item.description}
</CardDescription>
</CardHeader>
<CardContent className="h-full flex-1 space-y-6">
<div className="text-3xl font-bold">
{item.price}
<span className="text-sm font-normal text-muted-foreground">
/month
</span>
</div>
<div className="space-y-2">
{item.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="aspect-square shrink-0 rounded-full bg-foreground p-px text-background">
<CheckIcon className="size-4" aria-hidden="true" />
</div>
<span className="text-sm text-muted-foreground">
{feature}
</span>
</div>
))}
</div>
</CardContent>
<CardFooter className="pt-4">
{item.name === "Free" ? (
<Button className="w-full" asChild>
<Link href="/dashboard">
Get started
<span className="sr-only">Get started</span>
</Link>
</Button>
) : (
<ManageSubscriptionForm
stripePriceId={item.stripePriceId}
isPro={plan?.isPro ?? false}
stripeCustomerId={plan?.stripeCustomerId}
stripeSubscriptionId={plan?.stripeSubscriptionId}
/>
)}
</CardFooter>
</Card>
))}
</section>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,14 @@ export function ManageSubscriptionForm({
stripePriceId,
}: ManageSubscriptionFormProps) {
const [isPending, startTransition] = React.useTransition();
const manageSubscriptionMutation =
api.stripe.manageSubscription.useMutation();
const managePlanMutation = api.stripe.managePlan.useMutation();

function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();

startTransition(async () => {
try {
const session = await manageSubscriptionMutation.mutateAsync({
const session = await managePlanMutation.mutateAsync({
isPro,
stripeCustomerId,
stripeSubscriptionId,
Expand Down
124 changes: 30 additions & 94 deletions src/app/(main)/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";

import { CheckIcon, ExclamationTriangleIcon } from "@/components/icons";
import { ExclamationTriangleIcon } from "@/components/icons";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { subscriptionPlans } from "@/config/subscriptions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { env } from "@/env";
import { validateRequest } from "@/lib/auth/validate-request";
import { api } from "@/trpc/server";
import { ManageSubscriptionForm } from "./_components/manage-subscription-form";
import { formatDate } from "@/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { APP_TITLE } from "@/lib/constants";
import { api } from "@/trpc/server";
import * as React from "react";
import { Billing } from "./_components/billing";
import { BillingSkeleton } from "./_components/billing-skeleton";

export const metadata: Metadata = {
metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
Expand All @@ -35,7 +25,10 @@ export default async function BillingPage() {
redirect("/signin");
}

const subscriptionPlan = await api.stripe.getSubscriptionPlan.query();
const stripePromises = Promise.all([
api.stripe.getPlans.query(),
api.stripe.getPlan.query(),
]);

return (
<div className="grid gap-8 py-10 md:py-8">
Expand All @@ -45,85 +38,28 @@ export default async function BillingPage() {
Manage your billing and subscription
</p>
</div>
{
<section>
<Alert className="!pl-10">
<ExclamationTriangleIcon className="h-6 w-6" />
<AlertTitle>This is a demo app.</AlertTitle>
<AlertDescription>
{APP_TITLE} app 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#cards"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Stripe docs
</a>
.
</AlertDescription>
</Alert>
</section>
}
<section>
<Card className="space-y-2 p-6">
<h3 className="text-lg font-semibold sm:text-xl">
{subscriptionPlan?.name ?? "Free"} plan
</h3>
<p className="text-sm text-muted-foreground">
{!subscriptionPlan?.isPro
? "The free plan is limited to 3 posts. Upgrade to the Pro plan to unlock unlimited posts."
: subscriptionPlan.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{subscriptionPlan?.stripeCurrentPeriodEnd
? formatDate(subscriptionPlan.stripeCurrentPeriodEnd)
: null}
</p>
</Card>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{subscriptionPlans.map((plan) => (
<Card key={plan.name} className="flex flex-col">
<CardHeader className="h-full">
<CardTitle className="line-clamp-1">{plan.name}</CardTitle>
<CardDescription className="line-clamp-2">
{plan.description}
</CardDescription>
</CardHeader>
<CardContent className="h-full flex-1 place-items-start space-y-2">
{plan.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="aspect-square shrink-0 rounded-full bg-foreground p-px text-background">
<CheckIcon className="size-4" aria-hidden="true" />
</div>
<span className="text-sm text-muted-foreground">
{feature}
</span>
</div>
))}
</CardContent>
<CardFooter className="pt-4">
{plan.name === "Free" ? (
<Button className="w-full" asChild>
<Link href="/dashboard">
Get started
<span className="sr-only">Get started</span>
</Link>
</Button>
) : (
<ManageSubscriptionForm
isPro={subscriptionPlan?.isPro ?? false}
stripePriceId={plan.stripePriceId}
stripeCustomerId={subscriptionPlan?.stripeCustomerId}
stripeSubscriptionId={subscriptionPlan?.stripeSubscriptionId}
/>
)}
</CardFooter>
</Card>
))}
<Alert className="p-6 [&>svg]:left-6 [&>svg]:top-6 [&>svg~*]:pl-10">
<ExclamationTriangleIcon className="h-6 w-6" />
<AlertTitle>This is a demo app.</AlertTitle>
<AlertDescription>
{APP_TITLE} app 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#cards"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Stripe docs
</a>
.
</AlertDescription>
</Alert>
</section>
<React.Suspense fallback={<BillingSkeleton />}>
<Billing stripePromises={stripePromises} />
</React.Suspense>
</div>
);
}
Loading

0 comments on commit 0d955dd

Please sign in to comment.