Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export const EmbeddedCheckoutForm = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return EmbeddedCheckout;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { TriangleAlertIcon } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { PlanPicker } from '@kit/billing-gateway/components';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
import { createTeamAccountCheckoutSession } from '../_lib/server/server-actions';
|
||||
|
||||
const EmbeddedCheckout = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return {
|
||||
default: EmbeddedCheckout,
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function TeamAccountCheckoutForm(params: {
|
||||
accountId: string;
|
||||
customerId: string | null | undefined;
|
||||
}) {
|
||||
const routeParams = useParams();
|
||||
const appEvents = useAppEvents();
|
||||
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const { execute, isPending } = useAction(createTeamAccountCheckoutSession, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.checkoutToken) {
|
||||
setCheckoutToken(data.checkoutToken);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(true);
|
||||
},
|
||||
});
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
onClose={() => setCheckoutToken(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// only allow trial if the user is not already a customer
|
||||
const canStartTrial = !params.customerId;
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'billing.manageTeamPlan'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'billing.manageTeamPlanDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlertIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<PlanPicker
|
||||
pending={isPending}
|
||||
config={billingConfig}
|
||||
canStartTrial={canStartTrial}
|
||||
onSubmit={({ planId, productId }) => {
|
||||
const slug = routeParams.account as string;
|
||||
|
||||
appEvents.emit({
|
||||
type: 'checkout.started',
|
||||
payload: {
|
||||
planId,
|
||||
account: slug,
|
||||
},
|
||||
});
|
||||
|
||||
execute({
|
||||
planId,
|
||||
productId,
|
||||
slug,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { BillingPortalCard } from '@kit/billing-gateway/components';
|
||||
|
||||
import { createBillingPortalSession } from '../_lib/server/server-actions';
|
||||
|
||||
export function TeamBillingPortalForm({
|
||||
accountId,
|
||||
slug,
|
||||
}: {
|
||||
accountId: string;
|
||||
slug: string;
|
||||
}) {
|
||||
const { execute } = useAction(createBillingPortalSession);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
execute({ accountId, slug });
|
||||
}}
|
||||
>
|
||||
<BillingPortalCard />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const TeamBillingPortalSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
slug: z.string().min(1),
|
||||
});
|
||||
|
||||
export const TeamCheckoutSchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
planId: z.string().min(1),
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
// billing imports
|
||||
import {
|
||||
TeamBillingPortalSchema,
|
||||
TeamCheckoutSchema,
|
||||
} from '../schema/team-billing.schema';
|
||||
import { createTeamBillingService } from './team-billing.service';
|
||||
|
||||
/**
|
||||
* @name enabled
|
||||
* @description This feature flag is used to enable or disable team account billing.
|
||||
*/
|
||||
const enabled = featureFlagsConfig.enableTeamAccountBilling;
|
||||
|
||||
/**
|
||||
* @name createTeamAccountCheckoutSession
|
||||
* @description Creates a checkout session for a team account.
|
||||
*/
|
||||
export const createTeamAccountCheckoutSession = authActionClient
|
||||
.inputSchema(TeamCheckoutSchema)
|
||||
.action(async ({ parsedInput: data }) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Team account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
return service.createCheckout(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a Billing Session Portal and redirects the user to the
|
||||
* provider's hosted instance
|
||||
*/
|
||||
export const createBillingPortalSession = authActionClient
|
||||
.inputSchema(TeamBillingPortalSchema)
|
||||
.action(async ({ parsedInput: params }) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Team account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession(params);
|
||||
|
||||
redirect(url);
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'server-only';
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import * as z from 'zod';
|
||||
|
||||
import { LineItemSchema } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { Database } from '~/lib/database.types';
|
||||
|
||||
import { TeamCheckoutSchema } from '../schema/team-billing.schema';
|
||||
|
||||
export function createTeamBillingService(client: SupabaseClient<Database>) {
|
||||
return new TeamBillingService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name TeamBillingService
|
||||
* @description Service for managing billing for team accounts.
|
||||
*/
|
||||
class TeamBillingService {
|
||||
private readonly namespace = 'billing.team-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name createCheckout
|
||||
* @description Creates a checkout session for a Team account
|
||||
*/
|
||||
async createCheckout(params: z.output<typeof TeamCheckoutSchema>) {
|
||||
// we require the user to be authenticated
|
||||
const { data: user } = await requireUser(this.client);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const accountId = params.accountId;
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
userId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Requested checkout session. Processing...`);
|
||||
|
||||
const api = createTeamAccountsApi(this.client);
|
||||
|
||||
// verify permissions to manage billing
|
||||
const hasPermission = await api.hasPermission({
|
||||
userId,
|
||||
accountId,
|
||||
permission: 'billing.manage',
|
||||
});
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
logger.warn(
|
||||
ctx,
|
||||
`User without permissions attempted to create checkout.`,
|
||||
);
|
||||
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
// here we have confirmed that the user has permission to manage billing for the account
|
||||
// so we go on and create a checkout session
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
|
||||
// retrieve the plan from the configuration
|
||||
// so we can assign the correct checkout data
|
||||
const { plan, product } = getPlanDetails(params.productId, params.planId);
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
const customerEmail = user.email;
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
||||
|
||||
// get variant quantities
|
||||
// useful for setting an initial quantity value for certain line items
|
||||
// such as per seat
|
||||
const variantQuantities = await this.getVariantQuantities(
|
||||
plan.lineItems,
|
||||
accountId,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
planId: plan.id,
|
||||
},
|
||||
`Creating checkout session...`,
|
||||
);
|
||||
|
||||
try {
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
plan,
|
||||
returnUrl,
|
||||
customerEmail,
|
||||
customerId,
|
||||
variantQuantities,
|
||||
enableDiscountField: product.enableDiscountField,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Error creating the checkout session`,
|
||||
);
|
||||
|
||||
throw new Error(`Checkout not created`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a new billing portal session for a team account
|
||||
* @param accountId
|
||||
* @param slug
|
||||
*/
|
||||
async createBillingPortalSession({
|
||||
accountId,
|
||||
slug,
|
||||
}: {
|
||||
accountId: string;
|
||||
slug: string;
|
||||
}) {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Billing portal session requested. Processing...`,
|
||||
);
|
||||
|
||||
const { data: user, error } = await requireUser(client);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// we require the user to have permissions to manage billing for the account
|
||||
const hasPermission = await api.hasPermission({
|
||||
userId,
|
||||
accountId,
|
||||
permission: 'billing.manage',
|
||||
});
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
logger.warn(
|
||||
{
|
||||
userId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`User without permissions attempted to create billing portal session.`,
|
||||
);
|
||||
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
customerId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Creating billing portal session...`,
|
||||
);
|
||||
|
||||
// get the billing gateway provider
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
|
||||
try {
|
||||
const returnUrl = getBillingPortalReturnUrl(slug);
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
// redirect the user to the billing portal
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
userId,
|
||||
customerId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
error,
|
||||
},
|
||||
`Billing Portal session was not created`,
|
||||
);
|
||||
|
||||
throw new Error(`Error creating Billing Portal`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves variant quantities for line items.
|
||||
*/
|
||||
private async getVariantQuantities(
|
||||
lineItems: z.output<typeof LineItemSchema>[],
|
||||
accountId: string,
|
||||
) {
|
||||
const variantQuantities: Array<{
|
||||
quantity: number;
|
||||
variantId: string;
|
||||
}> = [];
|
||||
|
||||
for (const lineItem of lineItems) {
|
||||
// check if the line item is a per seat type
|
||||
const isPerSeat = lineItem.type === 'per_seat';
|
||||
|
||||
if (isPerSeat) {
|
||||
// get the current number of members in the account
|
||||
const quantity = await this.getCurrentMembersCount(accountId);
|
||||
|
||||
const item = {
|
||||
quantity,
|
||||
variantId: lineItem.id,
|
||||
};
|
||||
|
||||
variantQuantities.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// set initial quantity for the line items
|
||||
return variantQuantities;
|
||||
}
|
||||
|
||||
private async getCurrentMembersCount(accountId: string) {
|
||||
const api = createTeamAccountsApi(this.client);
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
return count ?? 1;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
accountId,
|
||||
error,
|
||||
name: `billing.checkout`,
|
||||
},
|
||||
`Encountered an error while fetching the number of existing seats`,
|
||||
);
|
||||
|
||||
return Promise.reject(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl(accountSlug: string) {
|
||||
return getAccountUrl(pathsConfig.app.accountBillingReturn, accountSlug);
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl(accountSlug: string) {
|
||||
return getAccountUrl(pathsConfig.app.accountBilling, accountSlug);
|
||||
}
|
||||
|
||||
function getAccountUrl(path: string, slug: string) {
|
||||
return new URL(path, appConfig.url).toString().replace('[account]', slug);
|
||||
}
|
||||
|
||||
function getPlanDetails(productId: string, planId: string) {
|
||||
const product = billingConfig.products.find(
|
||||
(product) => product.id === productId,
|
||||
);
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const plan = product?.plans.find((plan) => plan.id === planId);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
return { plan, product };
|
||||
}
|
||||
48
apps/web/app/[locale]/home/[account]/billing/error.tsx
Normal file
48
apps/web/app/[locale]/home/[account]/billing/error.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function BillingErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useCaptureException(error);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<AppBreadcrumbs />} />
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Button variant={'outline'} onClick={reset}>
|
||||
<Trans i18nKey={'common.retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
apps/web/app/[locale]/home/[account]/billing/layout.tsx
Normal file
15
apps/web/app/[locale]/home/[account]/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function TeamAccountBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enableTeamAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default TeamAccountBillingLayout;
|
||||
133
apps/web/app/[locale]/home/[account]/billing/page.tsx
Normal file
133
apps/web/app/[locale]/home/[account]/billing/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { resolveProductPlan } from '@kit/billing-gateway';
|
||||
import {
|
||||
CurrentLifetimeOrderCard,
|
||||
CurrentSubscriptionCard,
|
||||
} from '@kit/billing-gateway/components';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
|
||||
import { loadTeamAccountBillingPage } from '../_lib/server/team-account-billing-page.loader';
|
||||
import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader';
|
||||
import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form';
|
||||
import { TeamBillingPortalForm } from './_components/team-billing-portal-form';
|
||||
|
||||
interface TeamAccountBillingPageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('teams');
|
||||
const title = t('billing.pageTitle');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
|
||||
const account = (await params).account;
|
||||
const workspace = await loadTeamWorkspace(account);
|
||||
const accountId = workspace.account.id;
|
||||
|
||||
const [subscription, order, customerId] =
|
||||
await loadTeamAccountBillingPage(accountId);
|
||||
|
||||
const variantId = subscription?.items[0]?.variant_id;
|
||||
const orderVariantId = order?.items[0]?.variant_id;
|
||||
|
||||
const subscriptionProductPlan = variantId
|
||||
? await resolveProductPlan(billingConfig, variantId, subscription.currency)
|
||||
: undefined;
|
||||
|
||||
const orderProductPlan = orderVariantId
|
||||
? await resolveProductPlan(billingConfig, orderVariantId, order.currency)
|
||||
: undefined;
|
||||
|
||||
const hasBillingData = subscription || order;
|
||||
|
||||
const canManageBilling =
|
||||
workspace.account.permissions.includes('billing.manage');
|
||||
|
||||
const shouldShowBillingPortal = canManageBilling && customerId;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title={<Trans i18nKey={'common.routes.billing'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
|
||||
<div className={cn(`flex max-w-2xl flex-col space-y-4`)}>
|
||||
<If condition={!hasBillingData}>
|
||||
<If
|
||||
condition={canManageBilling}
|
||||
fallback={<CannotManageBillingAlert />}
|
||||
>
|
||||
<TeamAccountCheckoutForm
|
||||
customerId={customerId}
|
||||
accountId={accountId}
|
||||
/>
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<If condition={subscription}>
|
||||
{(subscription) => {
|
||||
return (
|
||||
<CurrentSubscriptionCard
|
||||
subscription={subscription}
|
||||
product={subscriptionProductPlan!.product}
|
||||
plan={subscriptionProductPlan!.plan}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<If condition={order}>
|
||||
{(order) => {
|
||||
return (
|
||||
<CurrentLifetimeOrderCard
|
||||
order={order}
|
||||
product={orderProductPlan!.product}
|
||||
plan={orderProductPlan!.plan}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
{shouldShowBillingPortal ? (
|
||||
<TeamBillingPortalForm accountId={accountId} slug={account} />
|
||||
) : null}
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAccountBillingPage;
|
||||
|
||||
function CannotManageBillingAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing.cannotManageBillingAlertTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing.cannotManageBillingAlertDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
86
apps/web/app/[locale]/home/[account]/billing/return/page.tsx
Normal file
86
apps/web/app/[locale]/home/[account]/billing/return/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { BillingSessionStatus } from '@kit/billing-gateway/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
import { EmbeddedCheckoutForm } from '../_components/embedded-checkout-form';
|
||||
|
||||
interface SessionPageProps {
|
||||
searchParams: Promise<{
|
||||
session_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
|
||||
const sessionId = (await searchParams).session_id;
|
||||
|
||||
if (!sessionId) {
|
||||
redirect('../');
|
||||
}
|
||||
|
||||
const { customerEmail, checkoutToken } = await loadCheckoutSession(sessionId);
|
||||
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckoutForm
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'fixed top-48 left-0 z-50 mx-auto w-full'}>
|
||||
<BillingSessionStatus
|
||||
redirectPath={'../billing'}
|
||||
customerEmail={customerEmail ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BlurryBackdrop />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReturnCheckoutSessionPage;
|
||||
|
||||
function BlurryBackdrop() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCheckoutSession(sessionId: string) {
|
||||
await requireUserInServerComponent();
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const gateway = await getBillingGatewayProvider(client);
|
||||
|
||||
const session = await gateway.retrieveCheckoutSession({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const checkoutToken = session.isSessionOpen ? session.checkoutToken : null;
|
||||
|
||||
// otherwise - we show the user the return page
|
||||
// and display the details of the session
|
||||
return {
|
||||
status: session.status,
|
||||
customerEmail: session.customer.email,
|
||||
checkoutToken,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user