B2B-88: add starter kit structure and elements
This commit is contained in:
45
app/home/(user)/_components/home-account-selector.tsx
Normal file
45
app/home/(user)/_components/home-account-selector.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
app/home/(user)/_components/home-accounts-list.tsx
Normal file
61
app/home/(user)/_components/home-accounts-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/home/(user)/_components/home-add-account-button.tsx
Normal file
27
app/home/(user)/_components/home-add-account-button.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
app/home/(user)/_components/home-menu-navigation.tsx
Normal file
68
app/home/(user)/_components/home-menu-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
app/home/(user)/_components/home-mobile-navigation.tsx
Normal file
122
app/home/(user)/_components/home-mobile-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
app/home/(user)/_components/home-page-header.tsx
Normal file
12
app/home/(user)/_components/home-page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/home/(user)/_components/home-sidebar.tsx
Normal file
61
app/home/(user)/_components/home-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/home/(user)/_components/user-notifications.tsx
Normal file
16
app/home/(user)/_components/user-notifications.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
app/home/(user)/_lib/server/load-user-workspace.ts
Normal file
42
app/home/(user)/_lib/server/load-user-workspace.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PersonalAccountCheckoutSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
58
app/home/(user)/billing/_lib/server/server-actions.ts
Normal file
58
app/home/(user)/billing/_lib/server/server-actions.ts
Normal 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);
|
||||
},
|
||||
{},
|
||||
);
|
||||
202
app/home/(user)/billing/_lib/server/user-billing.service.ts
Normal file
202
app/home/(user)/billing/_lib/server/user-billing.service.ts
Normal 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();
|
||||
}
|
||||
7
app/home/(user)/billing/error.tsx
Normal file
7
app/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
app/home/(user)/billing/layout.tsx
Normal file
15
app/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;
|
||||
92
app/home/(user)/billing/page.tsx
Normal file
92
app/home/(user)/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
app/home/(user)/billing/return/page.tsx
Normal file
5
app/home/(user)/billing/return/page.tsx
Normal 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
112
app/home/(user)/layout.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
3
app/home/(user)/loading.tsx
Normal file
3
app/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
32
app/home/(user)/page.tsx
Normal file
32
app/home/(user)/page.tsx
Normal 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);
|
||||
22
app/home/(user)/settings/layout.tsx
Normal file
22
app/home/(user)/settings/layout.tsx
Normal 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);
|
||||
50
app/home/(user)/settings/page.tsx
Normal file
50
app/home/(user)/settings/page.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user