feat: simplify billing ux (#2117)

This commit is contained in:
Ephraim Duncan
2025-11-13 04:58:16 +00:00
committed by GitHub
parent 74a03077b7
commit e4e04cdddc
10 changed files with 203 additions and 48 deletions

View File

@ -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',
@ -314,6 +302,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>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}

View File

@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,6 +17,8 @@ export type OrganisationBillingPortalButtonProps = {
export const OrganisationBillingPortalButton = ({
buttonProps,
}: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
@ -30,7 +34,10 @@ export const OrganisationBillingPortalButton = ({
const handleCreatePortal = async () => {
try {
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
const { redirectUrl } = await manageSubscription({
organisationId: organisation.id,
isPersonalLayoutMode: isPersonalLayout(organisations),
});
window.open(redirectUrl, '_blank');
} catch (err) {

View File

@ -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={isPersonalLayoutMode ? '/settings/billing-personal' : `/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"

View File

@ -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={isPersonalLayoutMode ? '/settings/billing-personal' : `/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"

View File

@ -0,0 +1,111 @@
import { useMemo } from 'react';
import { Trans, useLingui } 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 { 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';
export const UserBillingOrganisationsTable = () => {
const { t } = 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: t`Active`,
variant: 'default' as const,
}))
.with(SubscriptionStatus.PAST_DUE, () => ({
label: t`Past Due`,
variant: 'warning' as const,
}))
.with(SubscriptionStatus.INACTIVE, () => ({
label: t`Inactive`,
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t`Free`,
variant: 'neutral' as const,
}));
};
const columns = useMemo(() => {
return [
{
header: t`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: t`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: t`Actions`,
id: 'actions',
cell: ({ row }) => (
<Button asChild variant="outline">
<Link to={`/o/${row.original.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
),
},
] 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}
/>
);
};