From a600d47d6cfc505e590b2791fa1edf814bb2ff20 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 10 Mar 2024 19:36:36 +0600 Subject: [PATCH] feat: rete-limit store and product creation --- .../_components/dashboard-sidebar.tsx | 30 +-- .../dashboard/_components/store-switcher.tsx | 123 ++++++------ .../dashboard/billing/_components/billing.tsx | 16 +- .../billing/_components/usage-card.tsx | 32 ++-- .../(dashboard)/dashboard/billing/page.tsx | 3 +- src/app/(dashboard)/dashboard/layout.tsx | 27 ++- .../(dashboard)/dashboard/purchases/page.tsx | 2 +- .../dashboard/stores/[storeId]/layout.tsx | 2 +- .../stores/_components/add-store-dialog.tsx | 175 +++++++++++++----- src/app/(dashboard)/dashboard/stores/page.tsx | 10 +- src/components/cards/empty-card.tsx | 9 +- .../manage-subscription-form.tsx | 0 src/components/ui/command.tsx | 12 +- src/components/ui/dialog.tsx | 2 +- src/lib/actions/auth.ts | 20 -- src/lib/actions/store.ts | 26 --- src/lib/actions/user.ts | 93 ++++++++++ src/lib/subscription.ts | 50 ++--- 18 files changed, 363 insertions(+), 269 deletions(-) rename src/{app/(dashboard)/dashboard/billing/_components => components}/manage-subscription-form.tsx (100%) delete mode 100644 src/lib/actions/auth.ts create mode 100644 src/lib/actions/user.ts diff --git a/src/app/(dashboard)/dashboard/_components/dashboard-sidebar.tsx b/src/app/(dashboard)/dashboard/_components/dashboard-sidebar.tsx index 05f9d192..45b08721 100644 --- a/src/app/(dashboard)/dashboard/_components/dashboard-sidebar.tsx +++ b/src/app/(dashboard)/dashboard/_components/dashboard-sidebar.tsx @@ -1,45 +1,23 @@ -"use client" - import * as React from "react" -import { useParams } from "next/navigation" import { dashboardConfig } from "@/config/dashboard" -import { type getStoresByUserId } from "@/lib/actions/store" -import { type getSubscriptionPlan } from "@/lib/actions/stripe" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" import { SidebarNav } from "@/components/layouts/sidebar-nav" -import { StoreSwitcher } from "./store-switcher" - interface DashboardSidebarProps extends React.HTMLAttributes { - promises: Promise<{ - stores: Awaited> - subscriptionPlan: Awaited> - }> + children: React.ReactNode } export function DashboardSidebar({ - promises, + children, className, ...props }: DashboardSidebarProps) { - const { stores, subscriptionPlan } = React.use(promises) - - const { storeId } = useParams<{ storeId: string }>() - - const currentStore = stores.find((store) => store.id === storeId) - return ( diff --git a/src/app/(dashboard)/dashboard/_components/store-switcher.tsx b/src/app/(dashboard)/dashboard/_components/store-switcher.tsx index a6f4b630..f8f43ee3 100644 --- a/src/app/(dashboard)/dashboard/_components/store-switcher.tsx +++ b/src/app/(dashboard)/dashboard/_components/store-switcher.tsx @@ -1,9 +1,7 @@ "use client" import * as React from "react" -import { usePathname, useRouter } from "next/navigation" -import { type Store } from "@/db/schema" -import type { UserSubscriptionPlan } from "@/types" +import { useParams, usePathname, useRouter } from "next/navigation" import { CaretSortIcon, CheckIcon, @@ -11,7 +9,8 @@ import { PlusCircledIcon, } from "@radix-ui/react-icons" -import type { getStoresByUserId } from "@/lib/actions/store" +import { type getStoresByUserId } from "@/lib/actions/store" +import { type getProgress } from "@/lib/actions/user" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -23,52 +22,62 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command" -import { Dialog, DialogTrigger } from "@/components/ui/dialog" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { AddStoreDialog } from "../stores/_components/add-store-dialog" + interface StoreSwitcherProps extends React.ComponentPropsWithoutRef { - currentStore?: Awaited>[number] - stores: Pick[] - subscriptionPlan: UserSubscriptionPlan | null + userId: string + storesPromise: ReturnType + progressPromise: ReturnType } export function StoreSwitcher({ - currentStore, - stores, - subscriptionPlan, + userId, + storesPromise, + progressPromise, className, ...props }: StoreSwitcherProps) { + const { storeId } = useParams<{ storeId: string }>() const router = useRouter() const pathname = usePathname() const [open, setOpen] = React.useState(false) - const [showStoreDialog, setShowStoreDialog] = React.useState(false) + const [showNewStoreDialog, setShowNewStoreDialog] = React.useState(false) + + const stores = React.use(storesPromise) + + const selectedStore = stores.find((store) => store.id === storeId) return ( - + <> + @@ -76,58 +85,46 @@ export function StoreSwitcher({ No store found. - - {stores.map((store) => ( - { - router.push( - pathname.replace( - String(currentStore?.id), - String(store.id) - ) - ) - setOpen(false) - }} - className="text-sm" - > - - ))} - + {stores.map((store) => ( + { + setOpen(false) + router.push(`/dashboard/stores/${store.id}`) + }} + className="text-sm" + > + + ))} - - { - setOpen(false) - setShowStoreDialog(true) - }} - > - - + { + setOpen(false) + setShowNewStoreDialog(true) + }} + > + - + ) } diff --git a/src/app/(dashboard)/dashboard/billing/_components/billing.tsx b/src/app/(dashboard)/dashboard/billing/_components/billing.tsx index d6a194df..899c4688 100644 --- a/src/app/(dashboard)/dashboard/billing/_components/billing.tsx +++ b/src/app/(dashboard)/dashboard/billing/_components/billing.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import type { SubscriptionPlanWithPrice, UserSubscriptionPlan } from "@/types" import { CheckIcon } from "@radix-ui/react-icons" -import { getPlanLimits } from "@/lib/subscription" +import { getUsageWithProgress } from "@/lib/subscription" import { cn, formatDate } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -14,8 +14,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { ManageSubscriptionForm } from "@/components/manage-subscription-form" -import { ManageSubscriptionForm } from "./manage-subscription-form" import { UsageCard } from "./usage-card" interface BillingProps { @@ -38,12 +38,12 @@ export async function Billing({ 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) + const { storeLimit, storeProgress, productLimit, productProgress } = + getUsageWithProgress({ + planTitle: subscriptionPlan?.title ?? "free", + storeCount: usage.storeCount, + productCount: usage.productCount, + }) return ( <> diff --git a/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx b/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx index 90a0977d..1288445d 100644 --- a/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx +++ b/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx @@ -20,7 +20,7 @@ interface UsageCardProps { usage: number limit: number progress: number - moreInfo: string + moreInfo?: string } export function UsageCard({ @@ -33,21 +33,23 @@ export function UsageCard({ return ( -
+
{title} - - - - - -

{moreInfo}

-
-
+ {moreInfo && ( + + + + + +

{moreInfo}

+
+
+ )}
{usage} / {limit} stores ({progress}%) diff --git a/src/app/(dashboard)/dashboard/billing/page.tsx b/src/app/(dashboard)/dashboard/billing/page.tsx index c3113fdd..af4b0a0d 100644 --- a/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/src/app/(dashboard)/dashboard/billing/page.tsx @@ -4,9 +4,8 @@ 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 { getCacheduser, getUsage } from "@/lib/actions/user" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { PageHeader, diff --git a/src/app/(dashboard)/dashboard/layout.tsx b/src/app/(dashboard)/dashboard/layout.tsx index 82eed067..0a7b4ccc 100644 --- a/src/app/(dashboard)/dashboard/layout.tsx +++ b/src/app/(dashboard)/dashboard/layout.tsx @@ -1,14 +1,14 @@ import { redirect } from "next/navigation" -import { getCacheduser } from "@/lib/actions/auth" import { getStoresByUserId } from "@/lib/actions/store" -import { getSubscriptionPlan } from "@/lib/actions/stripe" +import { getCacheduser, getProgress } from "@/lib/actions/user" import { SiteFooter } from "@/components/layouts/site-footer" import { DashboardHeader } from "./_components/dashboard-header" import { DashboardSidebar } from "./_components/dashboard-sidebar" import { DashboardSidebarSheet } from "./_components/dashboard-sidebar-sheet" import { SidebarProvider } from "./_components/sidebar-provider" +import { StoreSwitcher } from "./_components/store-switcher" export default async function DashboardLayout({ children, @@ -19,25 +19,34 @@ export default async function DashboardLayout({ redirect("/signin") } - const promises = Promise.all([ - getStoresByUserId({ userId: user.id }), - getSubscriptionPlan({ userId: user.id }), - ]).then(([stores, subscriptionPlan]) => ({ stores, subscriptionPlan })) + const storesPromise = getStoresByUserId({ userId: user.id }) + const progressPromise = getProgress({ userId: user.id }) return (
- + + +
+ > + +
{children}
diff --git a/src/app/(dashboard)/dashboard/purchases/page.tsx b/src/app/(dashboard)/dashboard/purchases/page.tsx index 008d2c31..30b0743b 100644 --- a/src/app/(dashboard)/dashboard/purchases/page.tsx +++ b/src/app/(dashboard)/dashboard/purchases/page.tsx @@ -7,7 +7,7 @@ import { env } from "@/env.js" import type { SearchParams } from "@/types" import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm" -import { getCacheduser } from "@/lib/actions/auth" +import { getCacheduser } from "@/lib/actions/user" import { getUserEmail } from "@/lib/utils" import { purchasesSearchParamsSchema } from "@/lib/validations/params" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" diff --git a/src/app/(dashboard)/dashboard/stores/[storeId]/layout.tsx b/src/app/(dashboard)/dashboard/stores/[storeId]/layout.tsx index 2e5cc134..814c70ff 100644 --- a/src/app/(dashboard)/dashboard/stores/[storeId]/layout.tsx +++ b/src/app/(dashboard)/dashboard/stores/[storeId]/layout.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation" -import { getCacheduser } from "@/lib/actions/auth" +import { getCacheduser } from "@/lib/actions/user" import { PageHeader, PageHeaderDescription, diff --git a/src/app/(dashboard)/dashboard/stores/_components/add-store-dialog.tsx b/src/app/(dashboard)/dashboard/stores/_components/add-store-dialog.tsx index d71a2080..7da7dc09 100644 --- a/src/app/(dashboard)/dashboard/stores/_components/add-store-dialog.tsx +++ b/src/app/(dashboard)/dashboard/stores/_components/add-store-dialog.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner" import type { z } from "zod" import { addStore } from "@/lib/actions/store" -import { type getSubscriptionPlan } from "@/lib/actions/stripe" +import { type getProgress } from "@/lib/actions/user" import { cn } from "@/lib/utils" import { addStoreSchema } from "@/lib/validations/store" import { useMediaQuery } from "@/hooks/use-media-query" @@ -39,30 +39,39 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Icons } from "@/components/icons" +import { ManageSubscriptionForm } from "@/components/manage-subscription-form" interface AddStoreDialogProps - extends React.ComponentPropsWithRef { + extends React.ComponentPropsWithoutRef { userId: string - subscriptionPlanPromise: ReturnType + progressPromise: ReturnType + showTrigger?: boolean } type Inputs = z.infer export function AddStoreDialog({ userId, - subscriptionPlanPromise, + progressPromise, + onOpenChange, + showTrigger = true, ...props }: AddStoreDialogProps) { - const subscriptionPlan = React.use(subscriptionPlanPromise) - const router = useRouter() const [open, setOpen] = React.useState(false) const [loading, setLoading] = React.useState(false) const isDesktop = useMediaQuery("(min-width: 640px)") + const progress = React.use(progressPromise) + // react-hook-form const form = useForm({ resolver: zodResolver(addStoreSchema), @@ -104,12 +113,15 @@ export function AddStoreDialog({ form.reset() } setOpen(open) + onOpenChange?.(open) }} {...props} > - - - + Create a new store @@ -119,22 +131,7 @@ export function AddStoreDialog({ - - + @@ -150,12 +147,15 @@ export function AddStoreDialog({ form.reset() } setOpen(open) + onOpenChange?.(open) }} {...props} > - - - + Create a new store @@ -165,22 +165,7 @@ export function AddStoreDialog({ - - + @@ -244,3 +229,105 @@ function AddStoreForm({ ) } + +interface FormFooterProps { + loading: boolean + setOpen: React.Dispatch> +} + +function FormFooter({ loading, setOpen }: FormFooterProps) { + return ( + <> + + + + ) +} + +interface DynamicTriggerProps { + isDesktop: boolean + showTrigger?: boolean + progress: Awaited> +} + +function DynamicTrigger({ + showTrigger, + isDesktop, + progress, +}: DynamicTriggerProps) { + if (!showTrigger) return null + + const { + storeLimit, + storeProgress, + productLimit, + productProgress, + subscriptionPlan, + } = progress + + const limtReached = storeProgress === 100 || productProgress === 100 + + if (limtReached) { + return ( + + + + + + {storeProgress === 100 ? ( +
+ You can only create upto{" "} + {storeLimit} stores in your + current plan. +
+ ) : productProgress === 100 ? ( +
+ You can only create upto{" "} + {productLimit} products in your + current plan. +
+ ) : null} + {subscriptionPlan && subscriptionPlan.title !== "pro" ? ( + + ) : null} +
+
+ ) + } + + if (isDesktop) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/src/app/(dashboard)/dashboard/stores/page.tsx b/src/app/(dashboard)/dashboard/stores/page.tsx index a822a650..b0deb730 100644 --- a/src/app/(dashboard)/dashboard/stores/page.tsx +++ b/src/app/(dashboard)/dashboard/stores/page.tsx @@ -3,9 +3,8 @@ import type { Metadata } from "next" import { redirect } from "next/navigation" import { env } from "@/env.js" -import { getCacheduser } from "@/lib/actions/auth" import { getStoresByUserId } from "@/lib/actions/store" -import { getSubscriptionPlan } from "@/lib/actions/stripe" +import { getCacheduser, getProgress } from "@/lib/actions/user" import { PageHeader, PageHeaderDescription, @@ -31,7 +30,7 @@ export default async function StoresPage() { } const storesPromise = getStoresByUserId({ userId: user.id }) - const subscriptionPlanPromise = getSubscriptionPlan({ userId: user.id }) + const progressPromise = getProgress({ userId: user.id }) return ( @@ -40,10 +39,7 @@ export default async function StoresPage() { Stores - +
Manage your stores diff --git a/src/components/cards/empty-card.tsx b/src/components/cards/empty-card.tsx index 9908a1cd..2f2454fe 100644 --- a/src/components/cards/empty-card.tsx +++ b/src/components/cards/empty-card.tsx @@ -10,6 +10,7 @@ import { Icons } from "@/components/icons" interface EmptyCardProps extends React.ComponentPropsWithoutRef { title: string description?: string + action?: React.ReactNode icon?: keyof typeof Icons } @@ -17,6 +18,7 @@ export function EmptyCard({ title, description, icon = "placeholder", + action, className, ...props }: EmptyCardProps) { @@ -25,7 +27,7 @@ export function EmptyCard({ return (
- + {title} - {description} + {description ? {description} : null} + {action ? action : null} ) } diff --git a/src/app/(dashboard)/dashboard/billing/_components/manage-subscription-form.tsx b/src/components/manage-subscription-form.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/billing/_components/manage-subscription-form.tsx rename to src/components/manage-subscription-form.tsx diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 64dc9e2b..65661a52 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -23,18 +23,12 @@ const Command = React.forwardRef< )) Command.displayName = CommandPrimitive.displayName -interface CommandDialogProps extends DialogProps { - className?: string -} +interface CommandDialogProps extends DialogProps {} -const CommandDialog = ({ - children, - className, - ...props -}: CommandDialogProps) => { +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( - + {children} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 81131449..10f1f513 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< { - noStore() - try { - return await currentUser() - } catch (err) { - console.error(err) - return null - } -}) diff --git a/src/lib/actions/store.ts b/src/lib/actions/store.ts index e793c668..2b865aa9 100644 --- a/src/lib/actions/store.ts +++ b/src/lib/actions/store.ts @@ -164,32 +164,6 @@ export async function getStores(input: SearchParams) { } } -export async function getUsage(input: { userId: string }) { - noStore() - try { - const data = await db - .select({ - storeCount: count(stores.id), - productCount: count(products.id), - }) - .from(stores) - .leftJoin(products, eq(products.storeId, stores.id)) - .where(eq(stores.userId, input.userId)) - .execute() - .then((res) => res[0]) - - return { - storeCount: data?.storeCount ?? 0, - productCount: data?.productCount ?? 0, - } - } catch (err) { - return { - storeCount: 0, - productCount: 0, - } - } -} - export async function addStore( input: z.infer & { userId: string } ) { diff --git a/src/lib/actions/user.ts b/src/lib/actions/user.ts new file mode 100644 index 00000000..f1aa4559 --- /dev/null +++ b/src/lib/actions/user.ts @@ -0,0 +1,93 @@ +import "server-only" + +import { cache } from "react" +import { unstable_noStore as noStore } from "next/cache" +import { db } from "@/db" +import { products, stores } from "@/db/schema" +import { currentUser } from "@clerk/nextjs" +import { count, eq } from "drizzle-orm" + +import { getPlanLimits } from "../subscription" +import { getSubscriptionPlan } from "./stripe" + +/** + * Cache is used with a data-fetching function like fetch to share a data snapshot between components. + * It ensures a single request is made for multiple identical data fetches, with the returned data cached and shared across components during the server render. + * @see https://react.dev/reference/react/cache#reference + */ +export const getCacheduser = cache(async () => { + noStore() + try { + return await currentUser() + } catch (err) { + console.error(err) + return null + } +}) + +export async function getUsage(input: { userId: string }) { + noStore() + try { + const data = await db + .select({ + storeCount: count(stores.id), + productCount: count(products.id), + }) + .from(stores) + .leftJoin(products, eq(products.storeId, stores.id)) + .where(eq(stores.userId, input.userId)) + .execute() + .then((res) => res[0]) + + return { + storeCount: data?.storeCount ?? 0, + productCount: data?.productCount ?? 0, + } + } catch (err) { + return { + storeCount: 0, + productCount: 0, + } + } +} + +export async function getProgress(input: { userId: string }) { + noStore() + + const fallback = { + storeLimit: 0, + storeProgress: 0, + productLimit: 0, + productProgress: 0, + subscriptionPlan: null, + } + + try { + const subscriptionPlan = await getSubscriptionPlan({ userId: input.userId }) + + if (!subscriptionPlan) { + return fallback + } + + const { storeCount, productCount } = await getUsage({ + userId: input.userId, + }) + + const { storeLimit, productLimit } = getPlanLimits({ + planTitle: subscriptionPlan.title, + }) + + const storeProgress = Math.floor((storeCount / storeLimit) * 100) + const productProgress = Math.floor((productCount / productLimit) * 100) + + return { + storeLimit, + storeProgress, + productLimit, + productProgress, + subscriptionPlan, + } + } catch (err) { + return fallback + } +} diff --git a/src/lib/subscription.ts b/src/lib/subscription.ts index 257c7955..a59f81b6 100644 --- a/src/lib/subscription.ts +++ b/src/lib/subscription.ts @@ -1,25 +1,7 @@ -import type { SubscriptionPlan, UserSubscriptionPlan } from "@/types" +import type { SubscriptionPlan } from "@/types" import { subscriptionConfig } from "@/config/subscription" -export function getPlanFeatures(title: SubscriptionPlan["title"]) { - const plan = Object.values(subscriptionConfig.plans).find( - (plan) => plan.title === title - ) - const features = plan?.features.map((feature) => feature.split(",")).flat() - - const maxStoreCount = - features?.find((feature) => feature.match(/store/i))?.match(/\d+/) ?? 0 - - const maxProductCount = - features?.find((feature) => feature.match(/product/i))?.match(/\d+/) ?? 0 - - return { - maxStoreCount, - maxProductCount, - } -} - export function getPlanLimits({ planTitle, }: { @@ -35,22 +17,22 @@ export function getPlanLimits({ return { storeLimit: storeLimit ?? 0, productLimit: productLimit ?? 0 } } -export function getDashboardRedirectPath(input: { +export function getUsageWithProgress(input: { + planTitle: SubscriptionPlan["title"] storeCount: number - subscriptionPlan: UserSubscriptionPlan | null -}): string { - const { storeCount, subscriptionPlan } = input - - const minStoresWithProductCount = { - free: 1, - standard: 2, - pro: 3, - }[subscriptionPlan?.title ?? "free"] + productCount: number +}) { + const { storeLimit, productLimit } = getPlanLimits({ + planTitle: input.planTitle, + }) - const isActive = subscriptionPlan?.isActive ?? false - const hasEnoughStores = storeCount >= minStoresWithProductCount + const storeProgress = Math.floor((input.storeCount / storeLimit) * 100) + const productProgress = Math.floor((input.productCount / productLimit) * 100) - return isActive && hasEnoughStores - ? "/dashboard/billing" - : "/dashboard/stores/new" + return { + storeLimit, + storeProgress, + productLimit, + productProgress, + } }