mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
1 Commits
498a2be1c7
...
feat/billi
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d833c2348 |
@ -282,18 +282,6 @@ export const OrgMenuSwitcher = () => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>Account</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{currentOrganisation &&
|
||||
canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
@ -306,6 +294,18 @@ export const OrgMenuSwitcher = () => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/inbox">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>Account</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
|
||||
@ -16,7 +16,7 @@ import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -29,6 +29,10 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link to="/settings/profile">
|
||||
@ -127,21 +131,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -158,6 +147,21 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -17,7 +17,7 @@ import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -30,6 +30,10 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
@ -127,21 +131,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -158,6 +147,21 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -0,0 +1,136 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const BillingPortalButton = ({ organisationId }: { organisationId: string }) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.enterprise.billing.subscription.manage.useMutation();
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
try {
|
||||
const { redirectUrl } = await manageSubscription({ organisationId });
|
||||
window.open(redirectUrl, '_blank');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Unable to access billing portal. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleOpenPortal} loading={isPending}>
|
||||
<Trans>Manage Billing</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserBillingOrganisationsTable = () => {
|
||||
const { _ } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const billingOrganisations = useMemo(() => {
|
||||
return organisations.filter((org) =>
|
||||
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||
);
|
||||
}, [organisations]);
|
||||
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: _(msg`Active`),
|
||||
variant: 'default' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.PAST_DUE, () => ({
|
||||
label: _(msg`Past Due`),
|
||||
variant: 'warning' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.INACTIVE, () => ({
|
||||
label: _(msg`Inactive`),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
label: _(msg`Free`),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Organisation`),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => (
|
||||
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||
}
|
||||
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Subscription Status`),
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => {
|
||||
const subscription = row.original.subscription;
|
||||
const status = subscription?.status;
|
||||
const { label, variant } = getSubscriptionStatusDisplay(status);
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <BillingPortalButton organisationId={row.original.id} />,
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof billingOrganisations)[number]>[];
|
||||
}, [_, billingOrganisations]);
|
||||
|
||||
if (billingOrganisations.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
|
||||
<p className="text-sm">
|
||||
<Trans>You don't manage billing for any organisations.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={billingOrganisations}
|
||||
perPage={billingOrganisations.length}
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BillingPage;
|
||||
27
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
27
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { UserBillingOrganisationsTable } from '~/components/tables/user-billing-organisations-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Billing');
|
||||
}
|
||||
|
||||
export default function SettingsBilling() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Billing`)}
|
||||
subtitle={_(
|
||||
msg`Manage billing and subscriptions for organisations where you have billing management permissions.`,
|
||||
)}
|
||||
/>
|
||||
|
||||
<UserBillingOrganisationsTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user