mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 16:51:38 +10:00
feat: simplify billing ux (#2117)
This commit is contained in:
@ -282,18 +282,6 @@ export const OrgMenuSwitcher = () => {
|
|||||||
</DropdownMenuItem>
|
</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 &&
|
{currentOrganisation &&
|
||||||
canExecuteOrganisationAction(
|
canExecuteOrganisationAction(
|
||||||
'MANAGE_ORGANISATION',
|
'MANAGE_ORGANISATION',
|
||||||
@ -314,6 +302,18 @@ export const OrgMenuSwitcher = () => {
|
|||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
className="text-muted-foreground px-4 py-2"
|
className="text-muted-foreground px-4 py-2"
|
||||||
onClick={() => setLanguageSwitcherOpen(true)}
|
onClick={() => setLanguageSwitcherOpen(true)}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
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 { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -15,6 +17,8 @@ export type OrganisationBillingPortalButtonProps = {
|
|||||||
export const OrganisationBillingPortalButton = ({
|
export const OrganisationBillingPortalButton = ({
|
||||||
buttonProps,
|
buttonProps,
|
||||||
}: OrganisationBillingPortalButtonProps) => {
|
}: OrganisationBillingPortalButtonProps) => {
|
||||||
|
const { organisations } = useSession();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -30,7 +34,10 @@ export const OrganisationBillingPortalButton = ({
|
|||||||
|
|
||||||
const handleCreatePortal = async () => {
|
const handleCreatePortal = async () => {
|
||||||
try {
|
try {
|
||||||
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
|
const { redirectUrl } = await manageSubscription({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
isPersonalLayoutMode: isPersonalLayout(organisations),
|
||||||
|
});
|
||||||
|
|
||||||
window.open(redirectUrl, '_blank');
|
window.open(redirectUrl, '_blank');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -29,6 +29,10 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
|
||||||
|
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||||
|
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
<Link to="/settings/profile">
|
<Link to="/settings/profile">
|
||||||
@ -127,21 +131,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
|||||||
<Trans>Webhooks</Trans>
|
<Trans>Webhooks</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</Button>
|
||||||
</Link>
|
</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">
|
<Link to="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { Link, useLocation } from 'react-router';
|
|||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -30,6 +30,10 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
|||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
|
||||||
|
const hasManageableBillingOrgs = organisations.some((org) =>
|
||||||
|
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
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>
|
<Trans>Webhooks</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</Button>
|
||||||
</Link>
|
</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">
|
<Link to="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
24
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
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 { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title={t`Billing`}
|
||||||
|
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserBillingOrganisationsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -71,7 +71,7 @@ export const createSubscriptionRoute = authenticatedProcedure
|
|||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = isPersonalLayoutMode
|
const returnUrl = isPersonalLayoutMode
|
||||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||||
|
|
||||||
const redirectUrl = await createCheckoutSession({
|
const redirectUrl = await createCheckoutSession({
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { ZManageSubscriptionRequestSchema } from './manage-subscription.types';
|
|||||||
export const manageSubscriptionRoute = authenticatedProcedure
|
export const manageSubscriptionRoute = authenticatedProcedure
|
||||||
.input(ZManageSubscriptionRequestSchema)
|
.input(ZManageSubscriptionRequestSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { organisationId } = input;
|
const { organisationId, isPersonalLayoutMode } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
@ -93,9 +93,13 @@ export const manageSubscriptionRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returnUrl = isPersonalLayoutMode
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing-personal`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||||
|
|
||||||
const redirectUrl = await getPortalSession({
|
const redirectUrl = await getPortalSession({
|
||||||
customerId,
|
customerId,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
|
returnUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -2,4 +2,5 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const ZManageSubscriptionRequestSchema = z.object({
|
export const ZManageSubscriptionRequestSchema = z.object({
|
||||||
organisationId: z.string().describe('The organisation to manage the subscription for'),
|
organisationId: z.string().describe('The organisation to manage the subscription for'),
|
||||||
|
isPersonalLayoutMode: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user