feat: add direct marketing to billing subscription flow

This commit is contained in:
Ephraim Atta-Duncan
2025-07-11 11:48:49 +00:00
parent d6c11bd195
commit e04e5a7d2e
4 changed files with 156 additions and 52 deletions

6
.gitignore vendored
View File

@ -52,4 +52,8 @@ yarn-error.log*
!.vscode/extensions.json !.vscode/extensions.json
# logs # logs
logs.json logs.json
# claude
.claude
CLAUDE.md

View File

@ -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>

View File

@ -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

View 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;
}