B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 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/shadcn-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;
collisionPadding?: number;
}) {
const router = useRouter();
const context = useContext(SidebarContext);
return (
<AccountSelector
collapsed={!context?.open}
collisionPadding={props.collisionPadding ?? 20}
accounts={props.accounts}
features={features}
userId={props.userId}
onAccountChange={(value) => {
if (value) {
const path = pathsConfig.app.accountHome.replace('[account]', value);
router.replace(path);
}
}}
/>
);
}

View File

@@ -0,0 +1,61 @@
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 } = use(loadUserWorkspace());
if (!accounts.length) {
return <HomeAccountsListEmptyState />;
}
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} asChild>
<Link href={`/home/${account.value}`}>
<CardButtonHeader>
<CardButtonTitle>{account.label}</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
))}
</div>
</div>
);
}
function HomeAccountsListEmptyState() {
return (
<div className={'flex flex-1'}>
<EmptyState>
<EmptyStateButton asChild>
<HomeAddAccountButton className={'mt-4'} />
</EmptyStateButton>
<EmptyStateHeading>
<Trans i18nKey={'account:noTeamsYet'} />
</EmptyStateHeading>
<EmptyStateText>
<Trans i18nKey={'account:createTeam'} />
</EmptyStateText>
</EmptyState>
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useState } from 'react';
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
export function HomeAddAccountButton(props: { className?: string }) {
const [isAddingAccount, setIsAddingAccount] = useState(false);
return (
<>
<Button
className={props.className}
onClick={() => setIsAddingAccount(true)}
>
<Trans i18nKey={'account:createTeamButtonLabel'} />
</Button>
<CreateTeamAccountDialog
isOpen={isAddingAccount}
setIsOpen={setIsAddingAccount}
/>
</>
);
}

View File

@@ -0,0 +1,68 @@
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'}>
<UserNotifications userId={user.id} />
<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,122 @@
'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}
collisionPadding={0}
/>
</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 asChild key={props.path}>
<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>
</DropdownMenuItem>
);
}
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,61 @@
import { If } from '@kit/ui/if';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { cn } from '@kit/ui/utils';
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';
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
import { HomeAccountSelector } from './home-account-selector';
interface HomeSidebarProps {
workspace: UserWorkspace;
}
export function HomeSidebar(props: HomeSidebarProps) {
const { workspace, user, accounts } = props.workspace;
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-3'}>
<If
condition={featuresFlagConfig.enableTeamAccounts}
fallback={
<AppLogo
className={cn(
'p-2 group-data-[minimized=true]:max-w-full group-data-[minimized=true]:py-0',
)}
/>
}
>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</If>
<div className={'group-data-[minimized=true]:hidden'}>
<UserNotifications userId={user.id} />
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation config={personalAccountNavigationConfig} />
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer user={user} account={workspace} />
</SidebarFooter>
</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,42 @@
import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
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(),
]);
return {
accounts,
workspace,
user,
};
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useTransition } from 'react';
import dynamic from 'next/dynamic';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
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 [pending, startTransition] = useTransition();
const [error, setError] = useState(false);
const appEvents = useAppEvents();
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
// 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={'common:planCardLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'common:planCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-4'}>
<If condition={error}>
<ErrorAlert />
</If>
<PlanPicker
pending={pending}
config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => {
startTransition(async () => {
try {
appEvents.emit({
type: 'checkout.started',
payload: { planId },
});
const { checkoutToken } =
await createPersonalAccountCheckoutSession({
planId,
productId,
});
setCheckoutToken(checkoutToken);
} catch {
setError(true);
}
});
}}
/>
</CardContent>
</Card>
</div>
);
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'common:planPickerAlertErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:planPickerAlertErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

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

View File

@@ -0,0 +1,47 @@
import 'server-only';
import { cache } from 'react';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
/**
* The variable BILLING_MODE represents the billing mode for a service. It can
* have either the value 'subscription' or 'one-time'. If not provided, the default
* value is 'subscription'. The value can be overridden by the environment variable
* BILLING_MODE.
*
* If the value is 'subscription', we fetch the subscription data for the user.
* If the value is 'one-time', we fetch the orders data for the user.
* if none of these suits your needs, please override the below function.
*/
const BILLING_MODE = z
.enum(['subscription', 'one-time'])
.default('subscription')
.parse(process.env.BILLING_MODE);
/**
* 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 data =
BILLING_MODE === 'subscription'
? api.getSubscription(userId)
: api.getOrder(userId);
const customerId = api.getCustomerId(userId);
return Promise.all([data, customerId]);
}

View File

@@ -0,0 +1,58 @@
'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';
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 = enhanceAction(
async function (data) {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createUserBillingService(client);
return await service.createCheckoutSession(data);
},
{
schema: PersonalAccountCheckoutSchema,
},
);
/**
* @name createPersonalAccountBillingPortalSession
* @description Creates a billing Portal session for a personal account
*/
export const createPersonalAccountBillingPortalSession = enhanceAction(
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();
return redirect(url);
},
{},
);

View File

@@ -0,0 +1,202 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { 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.infer<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`);
}
}
/**
* @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`,
);
}
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,92 @@
import {
BillingPortalCard,
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 { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
import { createPersonalAccountBillingPortalSession } from '../billing/_lib/server/server-actions';
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('account:billingTab');
return {
title,
};
};
async function PersonalAccountBillingPage() {
const user = await requireUserInServerComponent();
const [data, customerId] = await loadPersonalAccountBillingPageData(user.id);
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common:routes.billing'} />}
description={<AppBreadcrumbs />}
/>
<PageBody>
<div className={'flex flex-col space-y-4'}>
<If condition={!data}>
<PersonalAccountCheckoutForm customerId={customerId} />
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</If>
<If condition={data}>
{(data) => (
<div className={'flex w-full max-w-2xl flex-col space-y-6'}>
{'active' in data ? (
<CurrentSubscriptionCard
subscription={data}
config={billingConfig}
/>
) : (
<CurrentLifetimeOrderCard
order={data}
config={billingConfig}
/>
)}
<If condition={!data}>
<PersonalAccountCheckoutForm customerId={customerId} />
</If>
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</div>
)}
</If>
</div>
</PageBody>
</>
);
}
export default withI18n(PersonalAccountBillingPage);
function CustomerBillingPortalForm() {
return (
<form action={createPersonalAccountBillingPortalSession}>
<BillingPortalCard />
</form>
);
}

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;

112
app/home/(user)/layout.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { withI18n } from '~/lib/i18n/with-i18n';
// 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 withI18n(UserHomeLayout);
function SidebarLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const state = use(getLayoutState());
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>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
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 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 === 'false'
: !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;

32
app/home/(user)/page.tsx Normal file
View File

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

View File

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

View File

@@ -0,0 +1,50 @@
import { use } from 'react';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import { PageBody } from '@kit/ui/page';
import authConfig from '~/config/auth.config';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
const features = {
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enablePasswordUpdate: authConfig.providers.password,
};
const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome;
const paths = {
callback: callbackPath + `?next=${accountHomePath}`,
};
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('account:settingsTab');
return {
title,
};
};
function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent());
return (
<PageBody>
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
/>
</div>
</PageBody>
);
}
export default withI18n(PersonalAccountSettingsPage);