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
928 lines
27 KiB
Plaintext
928 lines
27 KiB
Plaintext
---
|
|
status: "published"
|
|
title: 'Creating an Onboarding and Checkout flows'
|
|
label: 'Onboarding Checkout'
|
|
order: 2
|
|
description: 'Learn how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit.'
|
|
---
|
|
|
|
One popular request from customers is to have a way to onboard new users and guide them through the app, and have customers checkout before they can use the app.
|
|
|
|
In this guide, we'll show you how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit.
|
|
|
|
In this guide, we will cover:
|
|
|
|
1. Creating an onboarding flow when a user signs up.
|
|
2. Creating a multi-step form to have customers create a new Team Account
|
|
3. Creating a checkout flow to have customers pay before they can use the app.
|
|
4. Use Webhooks to update the user's record after they have paid.
|
|
|
|
Remember: you can customize the onboarding and checkout flow to fit your app's needs. This is a starting point, and you can build on top of it.
|
|
|
|
**Important:** Please make sure you have pulled the latest changes from the main branch before you start this guide.
|
|
|
|
## Step 0: Adding an Onboarding Table
|
|
|
|
Before we create the onboarding flow, let's add a new table to store the onboarding data.
|
|
|
|
Create a new migration using the following command:
|
|
|
|
```bash
|
|
pnpm --filter web supabase migration new onboarding
|
|
```
|
|
|
|
This will create a new migration file at `apps/web/supabase/migrations/<timestamp>_onboarding.sql`. Open this file and add the following SQL code:
|
|
|
|
```sql {% title="apps/web/supabase/migrations/<timestamp>_onboarding.sql" %}
|
|
create table if not exists public.onboarding (
|
|
id uuid primary key default uuid_generate_v4(),
|
|
account_id uuid references public.accounts(id) not null unique,
|
|
data jsonb default '{}',
|
|
completed boolean default false,
|
|
created_at timestamp with time zone default current_timestamp,
|
|
updated_at timestamp with time zone default current_timestamp
|
|
);
|
|
|
|
revoke all on public.onboarding from public, service_role;
|
|
|
|
grant select, update, insert on public.onboarding to authenticated;
|
|
grant select, delete on public.onboarding to service_role;
|
|
|
|
alter table onboarding enable row level security;
|
|
|
|
create policy read_onboarding
|
|
on public.onboarding
|
|
for select
|
|
to authenticated
|
|
using (account_id = (select auth.uid()));
|
|
|
|
create policy insert_onboarding
|
|
on public.onboarding
|
|
for insert
|
|
to authenticated
|
|
with check (account_id = (select auth.uid()));
|
|
|
|
create policy update_onboarding
|
|
on public.onboarding
|
|
for update
|
|
to authenticated
|
|
using (account_id = (select auth.uid()))
|
|
with check (account_id = (select auth.uid()));
|
|
```
|
|
|
|
This migration creates a new `onboarding` table with the following columns:
|
|
- `id`: A unique identifier for the onboarding record.
|
|
- `account_id`: A foreign key reference to the `accounts` table.
|
|
- `data`: A JSONB column to store the onboarding data.
|
|
- `completed`: A boolean flag to indicate if the onboarding is completed.
|
|
- `created_at` and `updated_at`: Timestamps for when the record was created and updated.
|
|
|
|
The migration also sets up row-level security policies to ensure that users can only access their own onboarding records.
|
|
|
|
Update your DB schema by running the following command:
|
|
|
|
```bash
|
|
pnpm run supabase:web:reset
|
|
```
|
|
|
|
And update your DB types by running the following command:
|
|
|
|
```bash
|
|
pnpm run supabase:web:typegen
|
|
```
|
|
|
|
Now that we have the `onboarding` table set up, let's create the onboarding flow.
|
|
|
|
## Step 1: Create the Onboarding Page
|
|
|
|
First, let's create the main onboarding page. This will be the entry point for our onboarding flow.
|
|
|
|
Create a new file at `apps/web/app/onboarding/page.tsx`:
|
|
|
|
```tsx {% title="apps/web/app/onboarding/page.tsx" %}
|
|
import { AppLogo } from '~/components/app-logo';
|
|
import { OnboardingForm } from './_components/onboarding-form';
|
|
|
|
function OnboardingPage() {
|
|
return (
|
|
<div className="flex h-screen flex-col items-center justify-center space-y-16">
|
|
<AppLogo />
|
|
|
|
<div>
|
|
<OnboardingForm />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default OnboardingPage;
|
|
```
|
|
|
|
This page is simple. It displays your app logo and the `OnboardingForm` component, which we'll create next.
|
|
|
|
## Step 2: Create the Onboarding Form Schema
|
|
|
|
Before we create the form, let's define its schema. This will help us validate the form data.
|
|
|
|
Create a new file at `apps/web/app/onboarding/_lib/onboarding-form.schema.ts`:
|
|
|
|
```typescript {% title="apps/web/app/onboarding/_lib/onboarding-form.schema.ts" %}
|
|
import * as z from 'zod';
|
|
|
|
export const OnboardingFormSchema = z.object({
|
|
profile: z.object({
|
|
name: z.string().min(1).max(255),
|
|
}),
|
|
team: z.object({
|
|
name: z.string().min(1).max(255),
|
|
}),
|
|
checkout: z.object({
|
|
planId: z.string().min(1),
|
|
productId: z.string().min(1),
|
|
}),
|
|
});
|
|
```
|
|
|
|
This schema defines the structure of our onboarding form. It has three main sections: profile, team, and checkout.
|
|
|
|
## Step 3: Create the Onboarding Form Component
|
|
|
|
Now, let's create the main `OnboardingForm` component. This is where the magic happens!
|
|
|
|
Create a new file at `apps/web/app/onboarding/_components/onboarding-form.tsx`:
|
|
|
|
```tsx {% title="apps/web/app/onboarding/_components/onboarding-form.tsx" %}
|
|
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import dynamic from 'next/dynamic';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useForm } from 'react-hook-form';
|
|
import * as z from 'zod';
|
|
|
|
import { PlanPicker } from '@kit/billing-gateway/components';
|
|
import { Button } from '@kit/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
} from '@kit/ui/form';
|
|
import { If } from '@kit/ui/if';
|
|
import { Input } from '@kit/ui/input';
|
|
import {
|
|
MultiStepForm,
|
|
MultiStepFormContextProvider,
|
|
MultiStepFormHeader,
|
|
MultiStepFormStep,
|
|
useMultiStepFormContext,
|
|
} from '@kit/ui/multi-step-form';
|
|
import { Stepper } from '@kit/ui/stepper';
|
|
|
|
import billingConfig from '~/config/billing.config';
|
|
import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema';
|
|
import { submitOnboardingFormAction } from '~/onboarding/_lib/server/server-actions';
|
|
|
|
const EmbeddedCheckout = dynamic(
|
|
async () => {
|
|
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
|
|
|
return {
|
|
default: EmbeddedCheckout,
|
|
};
|
|
},
|
|
{
|
|
ssr: false,
|
|
},
|
|
);
|
|
|
|
export function OnboardingForm() {
|
|
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(OnboardingFormSchema),
|
|
defaultValues: {
|
|
profile: {
|
|
name: '',
|
|
},
|
|
team: {
|
|
name: '',
|
|
},
|
|
checkout: {
|
|
planId: '',
|
|
productId: '',
|
|
},
|
|
},
|
|
mode: 'onBlur',
|
|
});
|
|
|
|
const onSubmit = useCallback(
|
|
async (data: z.infer<typeof OnboardingFormSchema>) => {
|
|
try {
|
|
const { checkoutToken } = await submitOnboardingFormAction(data);
|
|
|
|
setCheckoutToken(checkoutToken);
|
|
} catch (error) {
|
|
console.error('Failed to submit form:', error);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const checkoutPortalRef = useRef<HTMLDivElement>(null);
|
|
|
|
if (checkoutToken) {
|
|
return (
|
|
<EmbeddedCheckout
|
|
checkoutToken={checkoutToken}
|
|
provider={billingConfig.provider}
|
|
onClose={() => setCheckoutToken(undefined)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
'w-full rounded-lg p-8 shadow-sm duration-500 animate-in fade-in-90 zoom-in-95 slide-in-from-bottom-12 lg:border'
|
|
}
|
|
>
|
|
<MultiStepForm
|
|
className={'space-y-8 p-1'}
|
|
schema={OnboardingFormSchema}
|
|
form={form}
|
|
onSubmit={onSubmit}
|
|
>
|
|
<MultiStepFormHeader>
|
|
<MultiStepFormContextProvider>
|
|
{({ currentStepIndex }) => (
|
|
<Stepper
|
|
variant={'numbers'}
|
|
steps={['Profile', 'Team', 'Complete']}
|
|
currentStep={currentStepIndex}
|
|
/>
|
|
)}
|
|
</MultiStepFormContextProvider>
|
|
</MultiStepFormHeader>
|
|
|
|
<MultiStepFormStep name={'profile'}>
|
|
<ProfileStep />
|
|
</MultiStepFormStep>
|
|
|
|
<MultiStepFormStep name={'team'}>
|
|
<TeamStep />
|
|
</MultiStepFormStep>
|
|
|
|
<MultiStepFormStep name={'checkout'}>
|
|
<If condition={checkoutPortalRef.current}>
|
|
{(portalRef) => createPortal(<CheckoutStep />, portalRef)}
|
|
</If>
|
|
</MultiStepFormStep>
|
|
</MultiStepForm>
|
|
|
|
<div className={'p-1'} ref={checkoutPortalRef}></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileStep() {
|
|
const { nextStep, form } = useMultiStepFormContext();
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<div className={'flex flex-col space-y-6'}>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<h1 className={'text-xl font-semibold'}>Welcome to Makerkit</h1>
|
|
|
|
<p className={'text-sm text-muted-foreground'}>
|
|
Welcome to the onboarding process! Let's get started by
|
|
entering your name.
|
|
</p>
|
|
</div>
|
|
|
|
<FormField
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem>
|
|
<FormLabel>Your Name</FormLabel>
|
|
|
|
<FormControl>
|
|
<Input {...field} placeholder={'Name'} />
|
|
</FormControl>
|
|
|
|
<FormDescription>Enter your full name here</FormDescription>
|
|
</FormItem>
|
|
);
|
|
}}
|
|
name={'profile.name'}
|
|
/>
|
|
|
|
<div className={'flex justify-end'}>
|
|
<Button onClick={nextStep}>Continue</Button>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
function TeamStep() {
|
|
const { nextStep, prevStep, form } = useMultiStepFormContext();
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<div className={'flex w-full flex-col space-y-6'}>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<h1 className={'text-xl font-semibold'}>Create Your Team</h1>
|
|
|
|
<p className={'text-sm text-muted-foreground'}>
|
|
Let's create your team. Enter your team name below.
|
|
</p>
|
|
</div>
|
|
|
|
<FormField
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem>
|
|
<FormLabel>Your Team Name</FormLabel>
|
|
|
|
<FormControl>
|
|
<Input {...field} placeholder={'Name'} />
|
|
</FormControl>
|
|
|
|
<FormDescription>
|
|
This is the name of your team.
|
|
</FormDescription>
|
|
</FormItem>
|
|
);
|
|
}}
|
|
name={'team.name'}
|
|
/>
|
|
|
|
<div className={'flex justify-end space-x-2'}>
|
|
<Button variant={'ghost'} onClick={prevStep}>
|
|
Go Back
|
|
</Button>
|
|
|
|
<Button onClick={nextStep}>Continue</Button>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
function CheckoutStep() {
|
|
const { form, mutation } = useMultiStepFormContext();
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<div className={'flex w-full flex-col space-y-6 lg:min-w-[55rem]'}>
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<PlanPicker
|
|
pending={mutation.isPending}
|
|
config={billingConfig}
|
|
onSubmit={({ planId, productId }) => {
|
|
form.setValue('checkout.planId', planId);
|
|
form.setValue('checkout.productId', productId);
|
|
|
|
mutation.mutate();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
```
|
|
|
|
This component creates a multi-step form for the onboarding process. It includes steps for profile information, team creation, and plan selection.
|
|
|
|
## Step 4: Create the Server Action
|
|
|
|
Now, let's create the server action that will handle the form submission.
|
|
|
|
Create a new file at `apps/web/app/onboarding/_lib/server/server-actions.ts`:
|
|
|
|
```typescript {% title="apps/web/app/onboarding/_lib/server/server-actions.ts" %}
|
|
'use server';
|
|
|
|
import { redirect } from 'next/navigation';
|
|
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
import { authActionClient } from '@kit/next/safe-action';
|
|
import { getLogger } from '@kit/shared/logger';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
import appConfig from '~/config/app.config';
|
|
import billingConfig from '~/config/billing.config';
|
|
import pathsConfig from '~/config/paths.config';
|
|
import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema';
|
|
|
|
export const submitOnboardingFormAction = authActionClient
|
|
.inputSchema(OnboardingFormSchema)
|
|
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
|
const logger = await getLogger();
|
|
|
|
logger.info({ userId: user.id }, `Submitting onboarding form...`);
|
|
|
|
const isOnboarded = user.app_metadata.onboarded === true;
|
|
|
|
if (isOnboarded) {
|
|
logger.info(
|
|
{ userId: user.id },
|
|
`User is already onboarded. Redirecting...`,
|
|
);
|
|
|
|
redirect(pathsConfig.app.home);
|
|
}
|
|
|
|
const client = getSupabaseServerClient();
|
|
|
|
const createTeamResponse = await client
|
|
.from('accounts')
|
|
.insert({
|
|
name: data.team.name,
|
|
primary_owner_user_id: user.id,
|
|
is_personal_account: false,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (createTeamResponse.error) {
|
|
logger.error(
|
|
{
|
|
error: createTeamResponse.error,
|
|
},
|
|
`Failed to create team`,
|
|
);
|
|
|
|
throw createTeamResponse.error;
|
|
} else {
|
|
logger.info(
|
|
{ userId: user.id, teamId: createTeamResponse.data.id },
|
|
`Team created. Creating onboarding data...`,
|
|
);
|
|
}
|
|
|
|
const response = await client.from('onboarding').upsert(
|
|
{
|
|
account_id: user.id,
|
|
data: {
|
|
userName: data.profile.name,
|
|
teamAccountId: createTeamResponse.data.id,
|
|
},
|
|
completed: true,
|
|
},
|
|
{
|
|
onConflict: 'account_id',
|
|
},
|
|
);
|
|
|
|
if (response.error) {
|
|
throw response.error;
|
|
}
|
|
|
|
logger.info(
|
|
{ userId: user.id, teamId: createTeamResponse.data.id },
|
|
`Onboarding data created. Creating checkout session...`,
|
|
);
|
|
|
|
const billingService = createBillingGatewayService(billingConfig.provider);
|
|
|
|
const { plan, product } = getPlanDetails(
|
|
data.checkout.productId,
|
|
data.checkout.planId,
|
|
);
|
|
|
|
const returnUrl = new URL('/onboarding/complete', appConfig.url).href;
|
|
|
|
const checkoutSession = await billingService.createCheckoutSession({
|
|
returnUrl,
|
|
customerEmail: user.email,
|
|
accountId: createTeamResponse.data.id,
|
|
plan,
|
|
variantQuantities: [],
|
|
enableDiscountField: product.enableDiscountField,
|
|
metadata: {
|
|
source: 'onboarding',
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
return {
|
|
checkoutToken: checkoutSession.checkoutToken,
|
|
};
|
|
});
|
|
|
|
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 };
|
|
}
|
|
```
|
|
|
|
This server action handles the form submission, inserts the onboarding data into Supabase, create a team (so we can assign it a subscription), and creates a checkout session for the selected plan.
|
|
|
|
Once the checkout is completed, the user will be redirected to the `/onboarding/complete` page. This page will be created in the next step.
|
|
|
|
## Step 6: Enhancing the Stripe Webhook Handler
|
|
|
|
This change extends the functionality of the Stripe webhook handler to complete the onboarding process after a successful checkout. Here's what's happening:
|
|
|
|
In the `handleCheckoutSessionCompleted` method, we add new logic to handle onboarding-specific actions.
|
|
|
|
First, define the `completeOnboarding` function to process the onboarding data and create a team based on the user's input.
|
|
|
|
**Note:** This is valid for Stripe, but you can adapt it to any other payment provider.
|
|
|
|
```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
|
|
async function completeOnboarding(accountId: string) {
|
|
const logger = await getLogger();
|
|
const adminClient = getSupabaseServerAdminClient();
|
|
|
|
logger.info(
|
|
{ accountId },
|
|
`Checkout comes from onboarding. Processing onboarding data...`,
|
|
);
|
|
|
|
const onboarding = await adminClient
|
|
.from('onboarding')
|
|
.select('*')
|
|
.eq('account_id', accountId)
|
|
.single();
|
|
|
|
if (onboarding.error) {
|
|
logger.error(
|
|
{ error: onboarding.error, accountId },
|
|
`Failed to retrieve onboarding data`,
|
|
);
|
|
|
|
// if there's an error, we can't continue
|
|
return;
|
|
} else {
|
|
logger.info({ accountId }, `Onboarding data retrieved. Processing...`);
|
|
|
|
const data = onboarding.data.data as {
|
|
userName: string;
|
|
teamAccountId: string;
|
|
};
|
|
|
|
const teamAccountId = data.teamAccountId;
|
|
|
|
logger.info(
|
|
{ userId: accountId, teamAccountId },
|
|
`Assigning membership...`,
|
|
);
|
|
|
|
const assignMembershipResponse = await adminClient
|
|
.from('accounts_memberships')
|
|
.insert({
|
|
account_id: teamAccountId,
|
|
user_id: accountId,
|
|
account_role: 'owner',
|
|
});
|
|
|
|
if (assignMembershipResponse.error) {
|
|
logger.error(
|
|
{
|
|
error: assignMembershipResponse.error,
|
|
},
|
|
`Failed to assign membership`,
|
|
);
|
|
} else {
|
|
logger.info({ accountId }, `Membership assigned. Updating account...`);
|
|
}
|
|
|
|
const accountResponse = await adminClient
|
|
.from('accounts')
|
|
.update({
|
|
name: data.userName,
|
|
})
|
|
.eq('id', accountId);
|
|
|
|
if (accountResponse.error) {
|
|
logger.error(
|
|
{
|
|
error: accountResponse.error,
|
|
},
|
|
`Failed to update account`,
|
|
);
|
|
} else {
|
|
logger.info(
|
|
{ accountId },
|
|
`Account updated. Cleaning up onboarding data...`,
|
|
);
|
|
}
|
|
|
|
// set onboarded flag on user account
|
|
const updateUserResponse = await adminClient.auth.admin.updateUserById(
|
|
accountId,
|
|
{
|
|
app_metadata: {
|
|
onboarded: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (updateUserResponse.error) {
|
|
logger.error(
|
|
{
|
|
error: updateUserResponse.error,
|
|
},
|
|
`Failed to update user`,
|
|
);
|
|
} else {
|
|
logger.info({ accountId }, `User updated. Cleaning up...`);
|
|
}
|
|
|
|
// clean up onboarding data
|
|
const deleteOnboardingResponse = await adminClient
|
|
.from('onboarding')
|
|
.delete()
|
|
.eq('account_id', accountId);
|
|
|
|
if (deleteOnboardingResponse.error) {
|
|
logger.error(
|
|
{
|
|
error: deleteOnboardingResponse.error,
|
|
},
|
|
`Failed to delete onboarding data`,
|
|
);
|
|
} else {
|
|
logger.info(
|
|
{ accountId },
|
|
`Onboarding data cleaned up. Completed webhook handler.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Now, we handle this function in the `handleCheckoutSessionCompleted` method, right before the `onCheckoutCompletedCallback` is called.
|
|
|
|
```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
|
|
// ...
|
|
|
|
const subscriptionData =
|
|
await stripe.subscriptions.retrieve(subscriptionId);
|
|
|
|
const metadata = subscriptionData.metadata as {
|
|
source: string;
|
|
userId: string;
|
|
} | undefined;
|
|
|
|
// if the checkout comes from onboarding
|
|
// we need to complete the onboarding process
|
|
if (metadata?.source === 'onboarding') {
|
|
const userId = metadata.userId;
|
|
|
|
await completeOnboarding(userId);
|
|
}
|
|
|
|
return onCheckoutCompletedCallback(payload);
|
|
```
|
|
|
|
This enhanced webhook handler completes the onboarding process by creating a team account, updating the user's personal account, and marking the user as "onboarded" in their Supabase user metadata.
|
|
|
|
## Step 7: Handling the Onboarding Completion Page
|
|
|
|
The checkout will rediret to the `/onboarding/complete` page after the onboarding process is completed. This is because there is no telling when the webhook will be triggered.
|
|
|
|
In this page, we will start fetching the user data and verify if the user has been onboarded.
|
|
|
|
If the user has been onboarded correctly, we will redirect the user to the `/home` page. If not, we will show a loading spinner and keep checking the user's onboarded status until it is true.
|
|
|
|
```tsx {% title="/apps/web/app/onboarding/complete/page.tsx" %}
|
|
'use client';
|
|
|
|
import { useRef } from 'react';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
|
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
|
|
|
import pathsConfig from '~/config/paths.config';
|
|
|
|
export default function OnboardingCompletePage() {
|
|
const { error } = useCheckUserOnboarded();
|
|
|
|
if (error) {
|
|
return (
|
|
<div className={'flex flex-col items-center justify-center'}>
|
|
<p>Something went wrong...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <LoadingOverlay>Setting up your account...</LoadingOverlay>;
|
|
}
|
|
|
|
/**
|
|
* @description
|
|
* This function checks if the user is onboarded
|
|
* If the user is onboarded, it redirects them to the home page
|
|
* it retries every second until the user is onboarded
|
|
*/
|
|
function useCheckUserOnboarded() {
|
|
const client = useSupabase();
|
|
const countRef = useRef(0);
|
|
const maxCount = 10;
|
|
const error = countRef.current >= maxCount;
|
|
|
|
useQuery({
|
|
queryKey: ['onboarding-complete'],
|
|
refetchInterval: () => (error ? false : 1000),
|
|
queryFn: async () => {
|
|
if (error) {
|
|
return false;
|
|
}
|
|
|
|
countRef.current++;
|
|
|
|
const response = await client.auth.getUser();
|
|
|
|
if (response.error) {
|
|
throw response.error;
|
|
}
|
|
|
|
const onboarded = response.data.user.app_metadata.onboarded;
|
|
|
|
// if the user is onboarded, redirect them to the home page
|
|
if (onboarded) {
|
|
return window.location.assign(pathsConfig.app.home);
|
|
}
|
|
|
|
return false;
|
|
},
|
|
});
|
|
|
|
return {
|
|
error,
|
|
};
|
|
}
|
|
```
|
|
|
|
This page
|
|
|
|
## What This Means for Your Onboarding Flow
|
|
|
|
With these changes, your onboarding process now includes these additional steps:
|
|
|
|
1. When a user completes the checkout during onboarding, it triggers this enhanced webhook handler.
|
|
2. The handler retrieves the onboarding data that was saved earlier in the process.
|
|
3. It creates a new team account with the name provided during onboarding.
|
|
4. It updates the user's personal account with their name.
|
|
5. Finally, it marks the user as "onboarded" in their Supabase user metadata.
|
|
|
|
This completes the onboarding process, ensuring that all the information collected during onboarding is properly saved and the user's account is fully set up.
|
|
|
|
## Step 8: Update Your App's Routing
|
|
|
|
To integrate this onboarding flow into your app, you'll need to update your routing logic.
|
|
|
|
We can do this in the middleware, in the logic branch that handles the `/home` route. If the user is not logged in, we'll redirect them to the sign-in page. If the user is logged in but has not completed onboarding, we'll redirect them to the onboarding flow.
|
|
|
|
Update the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16):
|
|
|
|
```typescript {% title="apps/web/proxy.ts" %}
|
|
{
|
|
pattern: new URLPattern({ pathname: '/home/*?' }),
|
|
handler: async (req: NextRequest, res: NextResponse) => {
|
|
const {
|
|
data,
|
|
} = await getUser(req, res);
|
|
|
|
const origin = req.nextUrl.origin;
|
|
const next = req.nextUrl.pathname;
|
|
const claims = data?.claims;
|
|
|
|
// If user is not logged in, redirect to sign in page.
|
|
if (!claims) {
|
|
const signIn = pathsConfig.auth.signIn;
|
|
const redirectPath = `${signIn}?next=${next}`;
|
|
|
|
return NextResponse.redirect(new URL(redirectPath, origin).href);
|
|
}
|
|
|
|
// verify if user has completed onboarding
|
|
const isOnboarded = claims.app_metadata.onboarded;
|
|
|
|
// If user is logged in but has not completed onboarding,
|
|
if (!isOnboarded) {
|
|
return NextResponse.redirect(new URL('/onboarding', origin).href);
|
|
}
|
|
|
|
const supabase = createMiddlewareClient(req, res);
|
|
|
|
const requiresMultiFactorAuthentication =
|
|
await checkRequiresMultiFactorAuthentication(supabase);
|
|
|
|
// If user requires multi-factor authentication, redirect to MFA page.
|
|
if (requiresMultiFactorAuthentication) {
|
|
return NextResponse.redirect(
|
|
new URL(pathsConfig.auth.verifyMfa, origin).href,
|
|
);
|
|
}
|
|
},
|
|
}
|
|
```
|
|
|
|
This code checks if the user is logged in and has completed onboarding. If the user has not completed onboarding, they are redirected to the onboarding flow.
|
|
|
|
Also add the following pattern to make sure the user is authenticated when visiting the `/onboarding` route:
|
|
|
|
```typescript {% title="apps/web/proxy.ts" %}
|
|
{
|
|
pattern: new URLPattern({ pathname: '/onboarding/*?' }),
|
|
handler: async (req: NextRequest, res: NextResponse) => {
|
|
const {
|
|
data,
|
|
} = await getUser(req, res);
|
|
|
|
const claims = data?.claims;
|
|
|
|
// If user is not logged in, redirect to sign in page.
|
|
if (!claims) {
|
|
const signIn = pathsConfig.auth.signIn;
|
|
const redirectPath = `${signIn}?next=${next}`;
|
|
|
|
return NextResponse.redirect(new URL(redirectPath, origin).href);
|
|
}
|
|
|
|
const supabase = createMiddlewareClient(req, res);
|
|
|
|
const requiresMultiFactorAuthentication =
|
|
await checkRequiresMultiFactorAuthentication(supabase);
|
|
|
|
// If user requires multi-factor authentication, redirect to MFA page.
|
|
if (requiresMultiFactorAuthentication) {
|
|
return NextResponse.redirect(
|
|
new URL(pathsConfig.auth.verifyMfa, origin).href,
|
|
);
|
|
}
|
|
|
|
// verify if user has completed onboarding
|
|
const isOnboarded = claims.app_metadata.onboarded;
|
|
|
|
// If user completed onboarding, redirect to home page
|
|
if (isOnboarded) {
|
|
return NextResponse.redirect(new URL(pathsConfig.app.home, origin).href);
|
|
}
|
|
},
|
|
}
|
|
```
|
|
|
|
### Marking invited users as onboarded
|
|
|
|
When a user gets invited to a team account, you don't want to show them the onboarding flow again. You can use the `onboarded` property to mark the user as onboarded.
|
|
|
|
Update the `packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts` server action `acceptInvitationAction` at line 125 (eg. before increasing seats):
|
|
|
|
```tsx
|
|
// mark user as onboarded
|
|
await adminClient.auth.admin.updateUserById(user.id, {
|
|
app_metadata: {
|
|
onboarded: true,
|
|
},
|
|
});
|
|
```
|
|
|
|
In this way, the user will be redirected to the `/home` page after accepting the invite.
|
|
|
|
### Considerations
|
|
|
|
Remember, the user can always unsubscribe from the plan they selected during onboarding. You should handle this case in your billing system if your app always requires a plan to be active.
|
|
|
|
## Conclusion
|
|
|
|
That's it! You've now added an onboarding flow to your Makerkit Next.js Supabase Turbo project.
|
|
|
|
This flow includes:
|
|
|
|
1. A profile information step
|
|
2. A team creation step
|
|
3. A plan selection step
|
|
4. Integration with your billing system
|
|
|
|
Remember to style your components, handle errors gracefully, and test thoroughly. Happy coding! 🚀
|