diff --git a/src/app/(landing)/_components/feature-icons.tsx b/src/app/(landing)/_components/feature-icons.tsx index 85202ad..c44a9c3 100644 --- a/src/app/(landing)/_components/feature-icons.tsx +++ b/src/app/(landing)/_components/feature-icons.tsx @@ -311,8 +311,8 @@ const ReactEmail = forwardRef>( > + + + + + + + + + +
+ + +
+
+ + ); +} diff --git a/src/app/(main)/dashboard/_components/posts-skeleton.tsx b/src/app/(main)/dashboard/_components/posts-skeleton.tsx new file mode 100644 index 0000000..871b95e --- /dev/null +++ b/src/app/(main)/dashboard/_components/posts-skeleton.tsx @@ -0,0 +1,11 @@ +import { PostCardSkeleton } from "./post-card-skeleton"; + +export function PostsSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/src/app/(main)/dashboard/_components/posts.tsx b/src/app/(main)/dashboard/_components/posts.tsx index 9436422..61ab732 100644 --- a/src/app/(main)/dashboard/_components/posts.tsx +++ b/src/app/(main)/dashboard/_components/posts.tsx @@ -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. @@ -56,4 +63,4 @@ export const Posts = ({ posts, subscriptionPlan }: PostsProps) => { ))} ); -}; +} diff --git a/src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx b/src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx new file mode 100644 index 0000000..87ab039 --- /dev/null +++ b/src/app/(main)/dashboard/billing/_components/billing-skeleton.tsx @@ -0,0 +1,44 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function BillingSkeleton() { + return ( + <> +
+ + + + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + + +
+ ))} +
+ + ); +} diff --git a/src/app/(main)/dashboard/billing/_components/billing.tsx b/src/app/(main)/dashboard/billing/_components/billing.tsx new file mode 100644 index 0000000..e7cf8b7 --- /dev/null +++ b/src/app/(main)/dashboard/billing/_components/billing.tsx @@ -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 ( + <> +
+ +

+ {plan?.name ?? "Free"} plan +

+

+ {!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} +

+
+
+
+ {plans.map((item) => ( + + + {item.name} + + {item.description} + + + +
+ {item.price} + + /month + +
+
+ {item.features.map((feature) => ( +
+
+
+ + {feature} + +
+ ))} +
+
+ + {item.name === "Free" ? ( + + ) : ( + + )} + +
+ ))} +
+ + ); +} diff --git a/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx b/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx index 561b2c4..c8f863d 100644 --- a/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx +++ b/src/app/(main)/dashboard/billing/_components/manage-subscription-form.tsx @@ -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) { e.preventDefault(); startTransition(async () => { try { - const session = await manageSubscriptionMutation.mutateAsync({ + const session = await managePlanMutation.mutateAsync({ isPro, stripeCustomerId, stripeSubscriptionId, diff --git a/src/app/(main)/dashboard/billing/page.tsx b/src/app/(main)/dashboard/billing/page.tsx index 57e8049..bd746d5 100644 --- a/src/app/(main)/dashboard/billing/page.tsx +++ b/src/app/(main)/dashboard/billing/page.tsx @@ -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), @@ -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 (
@@ -45,85 +38,28 @@ export default async function BillingPage() { Manage your billing and subscription

- { -
- - - This is a demo app. - - {APP_TITLE} app is a demo app using a Stripe test environment. You - can find a list of test card numbers on the{" "} - - Stripe docs - - . - - -
- }
- -

- {subscriptionPlan?.name ?? "Free"} plan -

-

- {!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} -

-
-
-
- {subscriptionPlans.map((plan) => ( - - - {plan.name} - - {plan.description} - - - - {plan.features.map((feature) => ( -
-
-
- - {feature} - -
- ))} -
- - {plan.name === "Free" ? ( - - ) : ( - - )} - -
- ))} + + + This is a demo app. + + {APP_TITLE} app is a demo app using a Stripe test environment. You + can find a list of test card numbers on the{" "} + + Stripe docs + + . + +
+ }> + + ); } diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index e2194f4..a4198c6 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -1,8 +1,10 @@ import { env } from "@/env"; import { api } from "@/trpc/server"; import { type Metadata } from "next"; +import * as React from "react"; import { z } from "zod"; import { Posts } from "./_components/posts"; +import { PostsSkeleton } from "./_components/posts-skeleton"; export const metadata: Metadata = { metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), @@ -22,9 +24,15 @@ const schmea = z.object({ export default async function DashboardPage({ searchParams }: Props) { const { page } = schmea.parse(searchParams); - const [posts, subscriptionPlan] = await Promise.all([ + /** + * Passing multiple promises to `Promise.all` to fetch data in parallel to prevent waterfall requests. + * Passing promises to the `Posts` component to make them hot promises (they can run without being awaited) to prevent waterfall requests. + * @see https://www.youtube.com/shorts/A7GGjutZxrs + * @see https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#parallel-data-fetching + */ + const promises = Promise.all([ api.post.myPosts.query({ page }), - api.stripe.getSubscriptionPlan.query(), + api.stripe.getPlan.query(), ]); return ( @@ -33,7 +41,9 @@ export default async function DashboardPage({ searchParams }: Props) {

Posts

Manage your posts here

- + }> + + ); } diff --git a/src/config/subscriptions.ts b/src/config/subscriptions.ts index c2bd2b3..c42f6e1 100644 --- a/src/config/subscriptions.ts +++ b/src/config/subscriptions.ts @@ -1,16 +1,24 @@ import { env } from "@/env"; -export const subscriptionPlans = [ - { - name: "Free", - description: "The free plan is limited to 3 posts.", - features: ["Up to 3 posts", "Limited support"], - stripePriceId: "", - }, - { - name: "Pro", - description: "The Pro plan has unlimited posts.", - features: ["Unlimited posts", "Priority support"], - stripePriceId: env.STRIPE_PRO_MONTHLY_PLAN_ID, - }, -]; +export interface SubscriptionPlan { + name: string; + description: string; + features: string[]; + stripePriceId: string; +} + +export const freePlan: SubscriptionPlan = { + name: "Free", + description: "The free plan is limited to 3 posts.", + features: ["Up to 3 posts", "Limited support"], + stripePriceId: "", +}; + +export const proPlan: SubscriptionPlan = { + name: "Pro", + description: "The Pro plan has unlimited posts.", + features: ["Unlimited posts", "Priority support"], + stripePriceId: env.STRIPE_PRO_MONTHLY_PLAN_ID, +}; + +export const subscriptionPlans = [freePlan, proPlan]; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 89f6180..837174c 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,5 +1,5 @@ import { env } from "@/env"; -import { type ClassValue, clsx } from "clsx"; +import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { @@ -39,6 +39,18 @@ export function formatDate( }).format(new Date(date)); } +export function formatPrice( + price: number | string, + options: Intl.NumberFormatOptions = {}, +) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: options.currency ?? "USD", + notation: options.notation ?? "compact", + ...options, + }).format(Number(price)); +} + export function absoluteUrl(path: string) { return `${env.NEXT_PUBLIC_APP_URL}${path}`; } diff --git a/src/server/api/routers/stripe.ts b/src/server/api/routers/stripe.ts index f223fd1..4db573c 100644 --- a/src/server/api/routers/stripe.ts +++ b/src/server/api/routers/stripe.ts @@ -1,11 +1,42 @@ -import { subscriptionPlans } from "@/config/subscriptions"; -import { absoluteUrl } from "@/lib/utils"; +import { freePlan, proPlan, subscriptionPlans } from "@/config/subscriptions"; +import { stripe } from "@/lib/stripe"; +import { absoluteUrl, formatPrice } from "@/lib/utils"; import { manageSubscriptionSchema } from "@/lib/validators/stripe"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { stripe } from "@/lib/stripe"; export const stripeRouter = createTRPCRouter({ - getSubscriptionPlan: protectedProcedure.query(async ({ ctx }) => { + getPlans: protectedProcedure.query(async ({ ctx }) => { + try { + const user = await ctx.db.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, ctx.user.id), + columns: { + id: true, + }, + }); + + if (!user) { + throw new Error("User not found."); + } + + const proPrice = await stripe.prices.retrieve(proPlan.stripePriceId); + + return subscriptionPlans.map((plan) => { + return { + ...plan, + price: + plan.stripePriceId === proPlan.stripePriceId + ? formatPrice((proPrice.unit_amount ?? 0) / 100, { + currency: proPrice.currency, + }) + : formatPrice(0 / 100, { currency: proPrice.currency }), + }; + }); + } catch (err) { + console.error(err); + return []; + } + }), + getPlan: protectedProcedure.query(async ({ ctx }) => { try { const user = await ctx.db.query.users.findFirst({ where: (table, { eq }) => eq(table.id, ctx.user.id), @@ -26,7 +57,7 @@ export const stripeRouter = createTRPCRouter({ !!user.stripePriceId && (user.stripeCurrentPeriodEnd?.getTime() ?? 0) + 86_400_000 > Date.now(); - const plan = isPro ? subscriptionPlans[1] : subscriptionPlans[0]; + const plan = isPro ? proPlan : freePlan; // Check if user has canceled subscription let isCanceled = false; @@ -50,7 +81,7 @@ export const stripeRouter = createTRPCRouter({ return null; } }), - manageSubscription: protectedProcedure + managePlan: protectedProcedure .input(manageSubscriptionSchema) .mutation(async ({ ctx, input }) => { const billingUrl = absoluteUrl("/dashboard/billing");