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,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { TriangleAlert } 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 { createPersonalAccountCheckoutSession } from '../_lib/server/server-actions';
|
||||
|
||||
const EmbeddedCheckout = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return {
|
||||
default: EmbeddedCheckout,
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function PersonalAccountCheckoutForm(props: {
|
||||
customerId: string | null | undefined;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
const appEvents = useAppEvents();
|
||||
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { execute, isPending } = useAction(
|
||||
createPersonalAccountCheckoutSession,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.checkoutToken) {
|
||||
setCheckoutToken(data.checkoutToken);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// only allow trial if the user is not already a customer
|
||||
const canStartTrial = !props.customerId;
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
onClose={() => setCheckoutToken(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'billing.planCardLabel'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'billing.planCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<If condition={error}>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<PlanPicker
|
||||
pending={isPending}
|
||||
config={billingConfig}
|
||||
canStartTrial={canStartTrial}
|
||||
onSubmit={({ planId, productId }) => {
|
||||
appEvents.emit({
|
||||
type: 'checkout.started',
|
||||
payload: { planId },
|
||||
});
|
||||
|
||||
execute({
|
||||
planId,
|
||||
productId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing.planPickerAlertErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { BillingPortalCard } from '@kit/billing-gateway/components';
|
||||
|
||||
import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions';
|
||||
|
||||
export function PersonalBillingPortalForm() {
|
||||
const { execute } = useAction(createPersonalAccountBillingPortalSession);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
execute();
|
||||
}}
|
||||
>
|
||||
<BillingPortalCard />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const PersonalAccountCheckoutSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'server-only';
|
||||
import { cache } from 'react';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
/**
|
||||
* Load the personal account billing page data for the given user.
|
||||
* @param userId
|
||||
* @returns The subscription data or the orders data and the billing customer ID.
|
||||
* This function is cached per-request.
|
||||
*/
|
||||
export const loadPersonalAccountBillingPageData = cache(
|
||||
personalAccountBillingPageDataLoader,
|
||||
);
|
||||
|
||||
function personalAccountBillingPageDataLoader(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const subscription = api.getSubscription(userId);
|
||||
const order = api.getOrder(userId);
|
||||
const customerId = api.getCustomerId(userId);
|
||||
|
||||
return Promise.all([subscription, order, customerId]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'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';
|
||||
|
||||
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||
import { createUserBillingService } from './user-billing.service';
|
||||
|
||||
/**
|
||||
* @name enabled
|
||||
* @description This feature flag is used to enable or disable personal account billing.
|
||||
*/
|
||||
const enabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
/**
|
||||
* @name createPersonalAccountCheckoutSession
|
||||
* @description Creates a checkout session for a personal account.
|
||||
*/
|
||||
export const createPersonalAccountCheckoutSession = authActionClient
|
||||
.inputSchema(PersonalAccountCheckoutSchema)
|
||||
.action(async ({ parsedInput: data }) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Personal account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createUserBillingService(client);
|
||||
|
||||
return await service.createCheckoutSession(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* @name createPersonalAccountBillingPortalSession
|
||||
* @description Creates a billing Portal session for a personal account
|
||||
*/
|
||||
export const createPersonalAccountBillingPortalSession =
|
||||
authActionClient.action(async () => {
|
||||
if (!enabled) {
|
||||
throw new Error('Personal account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createUserBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession();
|
||||
|
||||
redirect(url);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'server-only';
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import * as z from 'zod';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getProductPlanPair } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
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 { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||
|
||||
export function createUserBillingService(client: SupabaseClient<Database>) {
|
||||
return new UserBillingService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name UserBillingService
|
||||
* @description Service for managing billing for personal accounts.
|
||||
*/
|
||||
class UserBillingService {
|
||||
private readonly namespace = 'billing.personal-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name createCheckoutSession
|
||||
* @description Create a checkout session for the user
|
||||
* @param planId
|
||||
* @param productId
|
||||
*/
|
||||
async createCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
}: z.output<typeof PersonalAccountCheckoutSchema>) {
|
||||
// get the authenticated user
|
||||
const { data: user, error } = await requireUser(this.client);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
|
||||
// in the case of personal accounts
|
||||
// the account ID is the same as the user ID
|
||||
const accountId = user.id;
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl();
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const api = createAccountsApi(this.client);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
const product = billingConfig.products.find(
|
||||
(item) => item.id === productId,
|
||||
);
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const { plan } = getProductPlanPair(billingConfig, planId);
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: `billing.personal-account`,
|
||||
planId,
|
||||
customerId,
|
||||
accountId,
|
||||
},
|
||||
`User requested a personal account checkout session. Contacting provider...`,
|
||||
);
|
||||
|
||||
try {
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
returnUrl,
|
||||
accountId,
|
||||
customerEmail: user.email,
|
||||
customerId,
|
||||
plan,
|
||||
variantQuantities: [],
|
||||
enableDiscountField: product.enableDiscountField,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
`Checkout session created. Returning checkout token to client...`,
|
||||
);
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
name: `billing.personal-account`,
|
||||
planId,
|
||||
customerId,
|
||||
accountId,
|
||||
error,
|
||||
},
|
||||
`Checkout session not created due to an error`,
|
||||
);
|
||||
|
||||
throw new Error(`Failed to create a checkout session`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Create a billing portal session for the user
|
||||
* @returns The URL to redirect the user to the billing portal
|
||||
*/
|
||||
async createBillingPortalSession() {
|
||||
const { data, error } = await requireUser(this.client);
|
||||
|
||||
if (error ?? !data) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
const logger = await getLogger();
|
||||
|
||||
const accountId = data.id;
|
||||
const api = createAccountsApi(this.client);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
const returnUrl = getBillingPortalReturnUrl();
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
customerId,
|
||||
accountId,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
`User requested a Billing Portal session. Contacting provider...`,
|
||||
);
|
||||
|
||||
let url: string;
|
||||
|
||||
try {
|
||||
const session = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
url = session.url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
...ctx,
|
||||
},
|
||||
`Failed to create a Billing Portal session`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Encountered an error creating the Billing Portal session`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(ctx, `Session successfully created.`);
|
||||
|
||||
// redirect user to billing portal
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl() {
|
||||
return new URL(
|
||||
pathsConfig.app.personalAccountBillingReturn,
|
||||
appConfig.url,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl() {
|
||||
return new URL(
|
||||
pathsConfig.app.personalAccountBilling,
|
||||
appConfig.url,
|
||||
).toString();
|
||||
}
|
||||
7
apps/web/app/[locale]/home/(user)/billing/error.tsx
Normal file
7
apps/web/app/[locale]/home/(user)/billing/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
// We reuse the page from the billing module
|
||||
// as there is no need to create a new one.
|
||||
import BillingErrorPage from '~/home/[account]/billing/error';
|
||||
|
||||
export default BillingErrorPage;
|
||||
15
apps/web/app/[locale]/home/(user)/billing/layout.tsx
Normal file
15
apps/web/app/[locale]/home/(user)/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function UserBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default UserBillingLayout;
|
||||
105
apps/web/app/[locale]/home/(user)/billing/page.tsx
Normal file
105
apps/web/app/[locale]/home/(user)/billing/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { resolveProductPlan } from '@kit/billing-gateway';
|
||||
import {
|
||||
CurrentLifetimeOrderCard,
|
||||
CurrentSubscriptionCard,
|
||||
} from '@kit/billing-gateway/components';
|
||||
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 billingConfig from '~/config/billing.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
// local imports
|
||||
import { HomeLayoutPageHeader } from '../_components/home-page-header';
|
||||
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
|
||||
import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form';
|
||||
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('account');
|
||||
const title = t('billingTab');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
async function PersonalAccountBillingPage() {
|
||||
const user = await requireUserInServerComponent();
|
||||
|
||||
const [subscription, order, customerId] =
|
||||
await loadPersonalAccountBillingPageData(user.id);
|
||||
|
||||
const subscriptionVariantId = subscription?.items[0]?.variant_id;
|
||||
const orderVariantId = order?.items[0]?.variant_id;
|
||||
|
||||
const subscriptionProductPlan =
|
||||
subscription && subscriptionVariantId
|
||||
? await resolveProductPlan(
|
||||
billingConfig,
|
||||
subscriptionVariantId,
|
||||
subscription.currency,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const orderProductPlan =
|
||||
order && orderVariantId
|
||||
? await resolveProductPlan(billingConfig, orderVariantId, order.currency)
|
||||
: undefined;
|
||||
|
||||
const hasBillingData = subscription || order;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'common.routes.billing'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
|
||||
<div className={'flex max-w-2xl flex-col space-y-4'}>
|
||||
<If
|
||||
condition={hasBillingData}
|
||||
fallback={
|
||||
<>
|
||||
<PersonalAccountCheckoutForm customerId={customerId} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col space-y-6'}>
|
||||
<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>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={customerId}>{() => <PersonalBillingPortalForm />}</If>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonalAccountBillingPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
// We reuse the page from the billing module
|
||||
// as there is no need to create a new one.
|
||||
import ReturnCheckoutSessionPage from '~/home/[account]/billing/return/page';
|
||||
|
||||
export default ReturnCheckoutSessionPage;
|
||||
Reference in New Issue
Block a user