mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 10:11:35 +10:00
feat: plan limits
This commit is contained in:
@ -4,8 +4,10 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
@ -19,7 +21,9 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types';
|
||||
export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
export type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
|
||||
export default function UserPage({ params }: { params: { id: number } }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -2,12 +2,15 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { quota, remaining } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (error instanceof TRPCClientError) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
You have reached your document limit.
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
You can upload up to {quota.documents} documents per month on your current plan.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className="text-primary hover:text-primary/80 mt-6 block font-medium"
|
||||
href="/settings/billing"
|
||||
>
|
||||
Upgrade your account to upload more documents.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<Header user={user} />
|
||||
<LimitsProvider>
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { redirect } from 'next/navigation';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
@ -30,12 +31,10 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
if (subscription?.planId) {
|
||||
const foundSubscriptionProduct = (await stripe.products.list()).data.find(
|
||||
(item) => item.default_price === subscription.planId,
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
subscriptionProduct = foundSubscriptionProduct ?? null;
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||
|
||||
Reference in New Issue
Block a user