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:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -0,0 +1,45 @@
'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import { SidebarContext } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const features = {
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
export function HomeAccountSelector(props: {
accounts: Array<{
label: string | null;
value: string | null;
image: string | null;
}>;
userId: string;
}) {
const router = useRouter();
const context = useContext(SidebarContext);
return (
<AccountSelector
collapsed={!context?.open}
accounts={props.accounts}
features={features}
userId={props.userId}
onAccountChange={(value) => {
if (value) {
document.cookie = `last-selected-team=${encodeURIComponent(value)}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
const path = pathsConfig.app.accountHome.replace('[account]', value);
router.replace(path);
}
}}
/>
);
}

View File

@@ -0,0 +1,75 @@
import { use } from 'react';
import Link from 'next/link';
import {
CardButton,
CardButtonHeader,
CardButtonTitle,
} from '@kit/ui/card-button';
import {
EmptyState,
EmptyStateButton,
EmptyStateHeading,
EmptyStateText,
} from '@kit/ui/empty-state';
import { Trans } from '@kit/ui/trans';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { HomeAddAccountButton } from './home-add-account-button';
export function HomeAccountsList() {
const { accounts, canCreateTeamAccount } = use(loadUserWorkspace());
if (!accounts.length) {
return (
<HomeAccountsListEmptyState canCreateTeamAccount={canCreateTeamAccount} />
);
}
return (
<div className="flex flex-col">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{accounts.map((account) => (
<CardButton
key={account.value}
render={
<Link href={`/home/${account.value}`}>
<CardButtonHeader>
<CardButtonTitle>{account.label}</CardButtonTitle>
</CardButtonHeader>
</Link>
}
/>
))}
</div>
</div>
);
}
function HomeAccountsListEmptyState(props: {
canCreateTeamAccount: { allowed: boolean; reason?: string };
}) {
return (
<div className={'flex flex-1'}>
<EmptyState>
<EmptyStateButton
render={
<HomeAddAccountButton
className={'mt-4'}
canCreateTeamAccount={props.canCreateTeamAccount}
/>
}
/>
<EmptyStateHeading>
<Trans i18nKey={'account.noTeamsYet'} />
</EmptyStateHeading>
<EmptyStateText>
<Trans i18nKey={'account.createTeam'} />
</EmptyStateText>
</EmptyState>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useState } from 'react';
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Button } from '@kit/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
interface HomeAddAccountButtonProps {
className?: string;
canCreateTeamAccount?: {
allowed: boolean;
reason?: string;
};
}
export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
const [isAddingAccount, setIsAddingAccount] = useState(false);
const canCreate = props.canCreateTeamAccount?.allowed ?? true;
const reason = props.canCreateTeamAccount?.reason;
const button = (
<Button
className={props.className}
onClick={() => setIsAddingAccount(true)}
disabled={!canCreate}
>
<Trans i18nKey={'account.createTeamButtonLabel'} />
</Button>
);
return (
<>
{!canCreate && reason ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span className="cursor-not-allowed">{button}</span>}
/>
<TooltipContent>
<Trans i18nKey={reason} defaults={reason} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
button
)}
<CreateTeamAccountDialog
isOpen={isAddingAccount}
setIsOpen={setIsAddingAccount}
/>
</>
);
}

View File

@@ -0,0 +1,70 @@
import {
BorderedNavigationMenu,
BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu';
import { If } from '@kit/ui/if';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import { UserNotifications } from '../_components/user-notifications';
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
const { workspace, user, accounts } = props.workspace;
const routes = personalAccountNavigationConfig.routes.reduce<
Array<{
path: string;
label: string;
Icon?: React.ReactNode;
end?: boolean | ((path: string) => boolean);
}>
>((acc, item) => {
if ('children' in item) {
return [...acc, ...item.children];
}
if ('divider' in item) {
return acc;
}
return [...acc, item];
}, []);
return (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<BorderedNavigationMenu>
{routes.map((route) => (
<BorderedNavigationMenuItem {...route} key={route.path} />
))}
</BorderedNavigationMenu>
</div>
<div className={'flex justify-end space-x-2.5'}>
<If condition={featuresFlagConfig.enableNotifications}>
<UserNotifications userId={user.id} />
</If>
<If condition={featuresFlagConfig.enableTeamAccounts}>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</If>
<div>
<ProfileAccountDropdownContainer
user={user}
account={workspace}
showProfileName={false}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import Link from 'next/link';
import { LogOut, Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
const signOut = useSignOut();
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={featuresFlagConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common.yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
key={props.path}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -0,0 +1,12 @@
import { PageHeader } from '@kit/ui/page';
export function HomeLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
}>,
) {
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
);
}

View File

@@ -0,0 +1,44 @@
import { If } from '@kit/ui/if';
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
interface HomeSidebarProps {
workspace: UserWorkspace;
}
export function HomeSidebar(props: HomeSidebarProps) {
const { workspace, user, accounts } = props.workspace;
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar variant="floating" collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-1'}>
<WorkspaceDropdown
user={user}
accounts={accounts}
workspace={workspace}
/>
<If condition={featuresFlagConfig.enableNotifications}>
<div className={'group-data-[collapsible=icon]:hidden'}>
<UserNotifications userId={user.id} />
</div>
</If>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation config={personalAccountNavigationConfig} />
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,16 @@
import { NotificationsPopover } from '@kit/notifications/components';
import featuresFlagConfig from '~/config/feature-flags.config';
export function UserNotifications(props: { userId: string }) {
if (!featuresFlagConfig.enableNotifications) {
return null;
}
return (
<NotificationsPopover
accountIds={[props.userId]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
);
}

View File

@@ -0,0 +1,82 @@
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/policies';
import featureFlagsConfig from '~/config/feature-flags.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
export type UserWorkspace = Awaited<ReturnType<typeof loadUserWorkspace>>;
/**
* @name loadUserWorkspace
* @description
* Load the user workspace data. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/
export const loadUserWorkspace = cache(workspaceLoader);
async function workspaceLoader() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const accountsPromise = shouldLoadAccounts
? () => api.loadUserAccounts()
: () => Promise.resolve([]);
const workspacePromise = api.getAccountWorkspace();
const [accounts, workspace, user] = await Promise.all([
accountsPromise(),
workspacePromise,
requireUserInServerComponent(),
]);
// If the user is not found or the workspace is not found, redirect to the home page - this may happen if the JWT is invalid or expired (ex. user deleted?)
if (!workspace || !user) {
redirect('/');
}
// Check if user can create team accounts (policy check)
const canCreateTeamAccount = shouldLoadAccounts
? await checkCanCreateTeamAccount(user.id)
: { allowed: false, reason: undefined };
return {
accounts,
workspace,
user,
canCreateTeamAccount,
};
}
/**
* Check if the user can create a team account based on policies.
* Preliminary checks run without account name - name validation happens during submission.
*/
async function checkCanCreateTeamAccount(userId: string) {
const evaluator = createAccountCreationPolicyEvaluator();
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
if (!hasPolicies) {
return { allowed: true, reason: undefined };
}
const context = {
timestamp: new Date().toISOString(),
userId,
accountName: '',
};
const result = await evaluator.canCreateAccount(context, 'preliminary');
return {
allowed: result.allowed,
reason: result.reasons[0],
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,6 @@
import * as z from 'zod';
export const PersonalAccountCheckoutSchema = z.object({
planId: z.string().min(1),
productId: z.string().min(1),
});

View File

@@ -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]);
}

View File

@@ -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);
});

View File

@@ -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();
}

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

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

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

View File

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

View File

@@ -0,0 +1,147 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import * as z from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/sidebar';
import { AppLogo } from '~/components/app-logo';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeMenuNavigation } from './_components/home-menu-navigation';
import { HomeMobileNavigation } from './_components/home-mobile-navigation';
import { HomeSidebar } from './_components/home-sidebar';
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>;
}
export default UserHomeLayout;
async function SidebarLayout({ children }: React.PropsWithChildren) {
const [workspace, state] = await Promise.all([
loadUserWorkspace().catch(() => null),
getLayoutState(),
]);
if (!workspace) {
redirect('/');
}
await redirectIfTeamsOnly(workspace);
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
async function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = await loadUserWorkspace();
await redirectIfTeamsOnly(workspace);
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
{children}
</Page>
</UserWorkspaceContextProvider>
);
}
function MobileNavigation({
workspace,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
}) {
return (
<>
<AppLogo />
<HomeMobileNavigation workspace={workspace} />
</>
);
}
async function redirectIfTeamsOnly(
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>,
) {
if (featuresFlagConfig.enableTeamsOnly) {
const firstTeam = workspace.accounts[0];
if (firstTeam?.value) {
const cookieStore = await cookies();
const lastSelected = cookieStore.get('last-selected-team')?.value;
const preferred = lastSelected
? workspace.accounts.find((a) => a.value === lastSelected)
: undefined;
const team = preferred ?? firstTeam;
redirect(pathsConfig.app.accountHome.replace('[account]', team.value!));
} else {
redirect(pathsConfig.app.createTeam);
}
}
}
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar_state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'true'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

@@ -0,0 +1,3 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -0,0 +1,29 @@
import { getTranslations } from 'next-intl/server';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { HomeLayoutPageHeader } from './_components/home-page-header';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('homePage');
return {
title,
};
};
function UserHomePage() {
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common.routes.home'} />}
description={<Trans i18nKey={'common.homeTabDescription'} />}
/>
</PageBody>
);
}
export default UserHomePage;

View File

@@ -0,0 +1,21 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account.routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{props.children}
</PageBody>
);
}
export default UserSettingsLayout;

View File

@@ -0,0 +1,58 @@
import { use } from 'react';
import { getTranslations } from 'next-intl/server';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import authConfig from '~/config/auth.config';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
// Show email option if password, magic link, or OTP is enabled
const showEmailOption =
authConfig.providers.password ||
authConfig.providers.magicLink ||
authConfig.providers.otp;
const features = {
showLinkEmailOption: showEmailOption,
enablePasswordUpdate: authConfig.providers.password,
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enableAccountLinking: authConfig.enableIdentityLinking,
};
const providers = authConfig.providers.oAuth;
const callbackPath = pathsConfig.auth.callback;
const accountSettingsPath = pathsConfig.app.accountSettings;
const paths = {
callback: callbackPath + `?next=${accountSettingsPath}`,
};
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('settingsTab');
return {
title,
};
};
function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent());
return (
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
providers={providers}
/>
</div>
);
}
export default PersonalAccountSettingsPage;