diff --git a/package.json b/package.json index 21d03636..8e626ab4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5b3d71..d71fe0de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ dependencies: '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 version: 2.0.6(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-hover-card': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.0(react@18.2.0) @@ -50,6 +53,9 @@ dependencies: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) @@ -2828,6 +2834,35 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-hover-card@1.0.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.64)(react@18.2.0) + '@types/react': 18.2.64 + '@types/react-dom': 18.2.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-icons@1.3.0(react@18.2.0): resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==} peerDependencies: @@ -3159,6 +3194,28 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.0 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.64 + '@types/react-dom': 18.2.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: diff --git a/src/app/(dashboard)/dashboard/billing/_components/billing-skeleton.tsx b/src/app/(dashboard)/dashboard/billing/_components/billing-skeleton.tsx index 9dca422c..4bb8170e 100644 --- a/src/app/(dashboard)/dashboard/billing/_components/billing-skeleton.tsx +++ b/src/app/(dashboard)/dashboard/billing/_components/billing-skeleton.tsx @@ -5,11 +5,29 @@ import { Skeleton } from "@/components/ui/skeleton" export function BillingSkeleton() { return ( <> - - - + + + + + + + {Array.from({ length: 2 }).map((_, i) => ( + + +
+ + +
+ +
+ + + +
+ ))} +
-
+
{Array.from({ length: 3 }).map((_, i) => ( - - + + - - + +
{Array.from({ length: 4 }).map((_, i) => (
diff --git a/src/app/(dashboard)/dashboard/billing/_components/billing.tsx b/src/app/(dashboard)/dashboard/billing/_components/billing.tsx index e9187fc2..d6a194df 100644 --- a/src/app/(dashboard)/dashboard/billing/_components/billing.tsx +++ b/src/app/(dashboard)/dashboard/billing/_components/billing.tsx @@ -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, @@ -14,37 +16,75 @@ import { } from "@/components/ui/card" import { ManageSubscriptionForm } from "./manage-subscription-form" +import { UsageCard } from "./usage-card" interface BillingProps { subscriptionPlanPromise: Promise subscriptionPlansPromise: Promise + 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 ( <> - - - {subscriptionPlan?.title ?? "Free"} - - - {!subscriptionPlan?.isSubscribed - ? "Upgrade to unlock more features." - : subscriptionPlan.isCanceled + + + Plan and Usage +
+ You're currently on the{" "} + + {subscriptionPlan?.title} + {" "} + plan.{" "} + {subscriptionPlan?.isCanceled ? "Your plan will be canceled on " : "Your plan renews on "} - {subscriptionPlan?.stripeCurrentPeriodEnd - ? `${formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}.` - : null} - + {subscriptionPlan?.stripeCurrentPeriodEnd ? ( + + {formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}. + + ) : null} +
+
+ + + +
{subscriptionPlans.map((plan, i) => ( @@ -54,15 +94,11 @@ export async function Billing({ "sm:col-span-2 lg:col-span-1": i === subscriptionPlans.length - 1, })} > - - - {plan.title} - - - {plan.description} - + + {plan.title} + {plan.description} - +
{plan.price} diff --git a/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx b/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx new file mode 100644 index 00000000..90a0977d --- /dev/null +++ b/src/app/(dashboard)/dashboard/billing/_components/usage-card.tsx @@ -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 ( + + +
+ {title} + + + + + +

{moreInfo}

+
+
+
+ + {usage} / {limit} stores ({progress}%) + +
+ + + +
+ ) +} diff --git a/src/app/(dashboard)/dashboard/billing/page.tsx b/src/app/(dashboard)/dashboard/billing/page.tsx index acff14fe..68f476b5 100644 --- a/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/src/app/(dashboard)/dashboard/billing/page.tsx @@ -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, @@ -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 ( - + Billing Manage your billing and subscription + + + Heads up! + + Skateshop 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/(lobby)/stores/page.tsx b/src/app/(lobby)/stores/page.tsx index ed0a8140..c5855786 100644 --- a/src/app/(lobby)/stores/page.tsx +++ b/src/app/(lobby)/stores/page.tsx @@ -2,7 +2,7 @@ import { type Metadata } from "next" import { env } from "@/env.js" import type { SearchParams } from "@/types" -import { getStores } from "@/lib/fetchers/store" +import { getStores } from "@/lib/actions/store" import { storesSearchParamsSchema } from "@/lib/validations/params" import { PageHeader, @@ -34,12 +34,7 @@ export default async function StoresPage({ searchParams }: StoresPageProps) { const limit = isNaN(perPageAsNumber) ? 10 : perPageAsNumber const offset = fallbackPage > 0 ? (fallbackPage - 1) * limit : 0 - const { data, pageCount } = await getStores({ - limit, - offset, - sort, - statuses, - }) + const { data, pageCount } = await getStores(searchParams) return ( diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..d0763650 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { Slot } from "@radix-ui/react-slot" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>