B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -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,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { PlanPicker } from '@kit/billing-gateway/components';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
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 [pending, startTransition] = useTransition();
|
||||
const appEvents = useAppEvents();
|
||||
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// 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>
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
canStartTrial={canStartTrial}
|
||||
onSubmit={({ planId, productId }) => {
|
||||
startTransition(async () => {
|
||||
const slug = routeParams.account as string;
|
||||
|
||||
appEvents.emit({
|
||||
type: 'checkout.started',
|
||||
payload: {
|
||||
planId,
|
||||
account: slug,
|
||||
},
|
||||
});
|
||||
|
||||
const { checkoutToken } = await createTeamAccountCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
slug,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { 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(),
|
||||
});
|
||||
65
app/home/[account]/billing/_lib/server/server-actions.ts
Normal file
65
app/home/[account]/billing/_lib/server/server-actions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
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 = enhanceAction(
|
||||
async (data) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Team account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
return service.createCheckout(data);
|
||||
},
|
||||
{
|
||||
schema: TeamCheckoutSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a Billing Session Portal and redirects the user to the
|
||||
* provider's hosted instance
|
||||
*/
|
||||
export const createBillingPortalSession = enhanceAction(
|
||||
async (formData: FormData) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Team account billing is not enabled');
|
||||
}
|
||||
|
||||
const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession(params);
|
||||
|
||||
return redirect(url);
|
||||
},
|
||||
{},
|
||||
);
|
||||
325
app/home/[account]/billing/_lib/server/team-billing.service.ts
Normal file
325
app/home/[account]/billing/_lib/server/team-billing.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { 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.infer<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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves variant quantities for line items.
|
||||
*/
|
||||
private async getVariantQuantities(
|
||||
lineItems: z.infer<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
app/home/[account]/billing/error.tsx
Normal file
48
app/home/[account]/billing/error.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
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'}>
|
||||
<ExclamationTriangleIcon 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
app/home/[account]/billing/layout.tsx
Normal file
15
app/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;
|
||||
135
app/home/[account]/billing/page.tsx
Normal file
135
app/home/[account]/billing/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import {
|
||||
BillingPortalCard,
|
||||
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';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// 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 { createBillingPortalSession } from './_lib/server/server-actions';
|
||||
|
||||
interface TeamAccountBillingPageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('teams: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 [data, customerId] = await loadTeamAccountBillingPage(accountId);
|
||||
|
||||
const canManageBilling =
|
||||
workspace.account.permissions.includes('billing.manage');
|
||||
|
||||
const Checkout = () => {
|
||||
if (!canManageBilling) {
|
||||
return <CannotManageBillingAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamAccountCheckoutForm customerId={customerId} accountId={accountId} />
|
||||
);
|
||||
};
|
||||
|
||||
const BillingPortal = () => {
|
||||
if (!canManageBilling || !customerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={createBillingPortalSession}>
|
||||
<input type="hidden" name={'accountId'} value={accountId} />
|
||||
<input type="hidden" name={'slug'} value={account} />
|
||||
|
||||
<BillingPortalCard />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title={<Trans i18nKey={'common:routes.billing'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div
|
||||
className={cn(`flex w-full flex-col space-y-4`, {
|
||||
'max-w-2xl': data,
|
||||
})}
|
||||
>
|
||||
<If
|
||||
condition={data}
|
||||
fallback={
|
||||
<div>
|
||||
<Checkout />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => {
|
||||
if ('active' in data) {
|
||||
return (
|
||||
<CurrentSubscriptionCard
|
||||
subscription={data}
|
||||
config={billingConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentLifetimeOrderCard order={data} config={billingConfig} />
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<BillingPortal />
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamAccountBillingPage);
|
||||
|
||||
function CannotManageBillingAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing:cannotManageBillingAlertTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing:cannotManageBillingAlertDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
87
app/home/[account]/billing/return/page.tsx
Normal file
87
app/home/[account]/billing/return/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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 { withI18n } from '~/lib/i18n/with-i18n';
|
||||
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 withI18n(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