mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
feat: add direct marketing to billing subscription flow
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@ -52,4 +52,8 @@ yarn-error.log*
|
|||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
logs.json
|
logs.json
|
||||||
|
|
||||||
|
# claude
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
@ -44,12 +44,22 @@ const MotionCard = motion(Card);
|
|||||||
|
|
||||||
export type BillingPlansProps = {
|
export type BillingPlansProps = {
|
||||||
plans: InternalClaimPlans;
|
plans: InternalClaimPlans;
|
||||||
|
selectedPlan?: string | null;
|
||||||
|
selectedCycle?: 'monthly' | 'yearly' | null;
|
||||||
|
isFromPricingPage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
export const BillingPlans = ({
|
||||||
|
plans,
|
||||||
|
selectedPlan,
|
||||||
|
selectedCycle,
|
||||||
|
isFromPricingPage,
|
||||||
|
}: BillingPlansProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>(
|
||||||
|
selectedCycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice',
|
||||||
|
);
|
||||||
|
|
||||||
const pricesToDisplay = useMemo(() => {
|
const pricesToDisplay = useMemo(() => {
|
||||||
const prices = [];
|
const prices = [];
|
||||||
@ -85,56 +95,65 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
|||||||
|
|
||||||
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{pricesToDisplay.map((price) => (
|
{pricesToDisplay.map((price) => {
|
||||||
<MotionCard
|
const planId = price.claim.toLowerCase().replace('claim_', '');
|
||||||
key={price.id}
|
const isSelected = selectedPlan && planId === selectedPlan;
|
||||||
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
|
||||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
|
||||||
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
|
||||||
>
|
|
||||||
<CardContent className="flex h-full flex-col p-6">
|
|
||||||
<CardTitle>{price.product.name}</CardTitle>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
return (
|
||||||
{price.friendlyPrice + ' '}
|
<MotionCard
|
||||||
<span className="text-xs">
|
key={price.id}
|
||||||
{interval === 'monthlyPrice' ? (
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||||
<Trans>per month</Trans>
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
) : (
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||||
<Trans>per year</Trans>
|
className={isSelected ? 'ring-primary ring-2' : ''}
|
||||||
)}
|
>
|
||||||
</span>
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
</div>
|
<CardTitle>{price.product.name}</CardTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||||
{price.product.description}
|
{price.friendlyPrice + ' '}
|
||||||
</div>
|
<span className="text-xs">
|
||||||
|
{interval === 'monthlyPrice' ? (
|
||||||
{price.product.features && price.product.features.length > 0 && (
|
<Trans>per month</Trans>
|
||||||
<div className="text-muted-foreground mt-4">
|
) : (
|
||||||
<div className="text-sm font-medium">Includes:</div>
|
<Trans>per year</Trans>
|
||||||
|
)}
|
||||||
<ul className="mt-1 divide-y text-sm">
|
</span>
|
||||||
{price.product.features.map((feature, index) => (
|
|
||||||
<li key={index} className="py-2">
|
|
||||||
{feature.name}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
{price.product.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
<BillingDialog
|
{price.product.features && price.product.features.length > 0 && (
|
||||||
priceId={price.id}
|
<div className="text-muted-foreground mt-4">
|
||||||
planName={price.product.name}
|
<div className="text-sm font-medium">Includes:</div>
|
||||||
memberCount={price.memberCount}
|
|
||||||
claim={price.claim}
|
<ul className="mt-1 divide-y text-sm">
|
||||||
/>
|
{price.product.features.map((feature, index) => (
|
||||||
</CardContent>
|
<li key={index} className="py-2">
|
||||||
</MotionCard>
|
{feature.name}
|
||||||
))}
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<BillingDialog
|
||||||
|
priceId={price.id}
|
||||||
|
planName={price.product.name}
|
||||||
|
memberCount={price.memberCount}
|
||||||
|
claim={price.claim}
|
||||||
|
isSelected={isSelected || false}
|
||||||
|
isFromPricingPage={isFromPricingPage}
|
||||||
|
interval={interval}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,13 +164,19 @@ const BillingDialog = ({
|
|||||||
priceId,
|
priceId,
|
||||||
planName,
|
planName,
|
||||||
claim,
|
claim,
|
||||||
|
isSelected,
|
||||||
|
isFromPricingPage,
|
||||||
|
interval,
|
||||||
}: {
|
}: {
|
||||||
priceId: string;
|
priceId: string;
|
||||||
planName: string;
|
planName: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
claim: string;
|
claim: string;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isFromPricingPage?: boolean;
|
||||||
|
interval: 'monthlyPrice' | 'yearlyPrice';
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(isSelected && isFromPricingPage);
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -227,11 +252,13 @@ const BillingDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>
|
||||||
|
Subscribe to {planName} {interval === 'monthlyPrice' ? '(Monthly)' : '(Yearly)'}
|
||||||
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>You are about to subscribe to the {planName}</Trans>
|
<Trans>Choose how to proceed with your subscription</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -19,9 +20,14 @@ export function meta() {
|
|||||||
|
|
||||||
export default function TeamsSettingBillingPage() {
|
export default function TeamsSettingBillingPage() {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const selectedPlan = searchParams.get('plan');
|
||||||
|
const selectedCycle = searchParams.get('cycle') as 'monthly' | 'yearly' | null;
|
||||||
|
const source = searchParams.get('source');
|
||||||
|
|
||||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||||
trpc.billing.subscription.get.useQuery({
|
trpc.billing.subscription.get.useQuery({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
@ -48,8 +54,21 @@ export default function TeamsSettingBillingPage() {
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
(stripeSubscription?.items.data[0].price.product as Stripe.Product | undefined)?.name;
|
(stripeSubscription?.items.data[0].price.product as Stripe.Product | undefined)?.name;
|
||||||
|
|
||||||
|
const isFromPricingPage = source === 'pricing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{isFromPricingPage && selectedPlan && !subscription && (
|
||||||
|
<div className="bg-muted mb-4 rounded-lg p-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
<Trans>
|
||||||
|
Select a plan below to upgrade <strong>{organisation.name}</strong> to the{' '}
|
||||||
|
{selectedPlan} plan
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row items-end justify-between">
|
<div className="flex flex-row items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">
|
<h3 className="text-2xl font-semibold">
|
||||||
@ -134,7 +153,14 @@ export default function TeamsSettingBillingPage() {
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
|
{!subscription && canManageBilling && (
|
||||||
|
<BillingPlans
|
||||||
|
plans={plans}
|
||||||
|
selectedPlan={selectedPlan}
|
||||||
|
selectedCycle={selectedCycle}
|
||||||
|
isFromPricingPage={source === 'pricing'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<OrganisationBillingInvoicesTable
|
<OrganisationBillingInvoicesTable
|
||||||
|
|||||||
47
apps/remix/app/routes/billing-redirect.tsx
Normal file
47
apps/remix/app/routes/billing-redirect.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
|
import { getOrganisations } from '@documenso/trpc/server/organisation-router/get-organisations';
|
||||||
|
|
||||||
|
import type { Route } from './+types/billing-redirect';
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const session = await getOptionalSession(request);
|
||||||
|
if (!session.isAuthenticated) {
|
||||||
|
throw redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const plan = url.searchParams.get('plan');
|
||||||
|
const cycle = url.searchParams.get('cycle');
|
||||||
|
const source = url.searchParams.get('source');
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (plan) {
|
||||||
|
queryParams.set('plan', plan);
|
||||||
|
}
|
||||||
|
if (cycle) {
|
||||||
|
queryParams.set('cycle', cycle);
|
||||||
|
}
|
||||||
|
if (source) {
|
||||||
|
queryParams.set('source', source);
|
||||||
|
}
|
||||||
|
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
|
||||||
|
|
||||||
|
const organisations = await getOrganisations({ userId: session.user.id });
|
||||||
|
if (isPersonalLayout(organisations)) {
|
||||||
|
return redirect(`/settings/billing${queryString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalOrg = organisations.find((org) => org.type === 'PERSONAL') || organisations[0];
|
||||||
|
if (personalOrg) {
|
||||||
|
return redirect(`/o/${personalOrg.url}/settings/billing${queryString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/settings/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BillingRedirect() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user