Remove redundant files and update pnpm lockfile
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
|
||||
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;
|
||||
}>;
|
||||
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
collapsed={props.collapsed}
|
||||
accounts={props.accounts}
|
||||
features={features}
|
||||
onAccountChange={(value) => {
|
||||
if (value) {
|
||||
const path = pathsConfig.app.accountHome.replace('[account]', value);
|
||||
router.replace(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BorderedNavigationMenu,
|
||||
BorderedNavigationMenuItem,
|
||||
} from '@kit/ui/bordered-navigation-menu';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
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 | undefined;
|
||||
}>
|
||||
>((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-4'}>
|
||||
<AppLogo />
|
||||
|
||||
<BorderedNavigationMenu>
|
||||
{routes.map((route) => (
|
||||
<BorderedNavigationMenuItem {...route} key={route.path} />
|
||||
))}
|
||||
</BorderedNavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-4'}>
|
||||
<HomeAccountSelector accounts={accounts} collapsed={false} />
|
||||
|
||||
<UserNotifications userId={user.id} />
|
||||
|
||||
<ProfileAccountDropdownContainer
|
||||
collapsed={true}
|
||||
user={user}
|
||||
account={workspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/web/app/home/(user)/_components/home-mobile-navigation.tsx
Normal file
130
apps/web/app/home/(user)/_components/home-mobile-navigation.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'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 (
|
||||
<DropdownLink
|
||||
key={item.path}
|
||||
Icon={item.Icon}
|
||||
path={item.path}
|
||||
label={item.label}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
accounts={props.workspace.accounts}
|
||||
collapsed={false}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
14
apps/web/app/home/(user)/_components/home-page-header.tsx
Normal file
14
apps/web/app/home/(user)/_components/home-page-header.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
export function HomeLayoutPageHeader(
|
||||
props: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<PageHeader title={props.title} description={props.description}>
|
||||
{props.children}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
42
apps/web/app/home/(user)/_components/home-sidebar.tsx
Normal file
42
apps/web/app/home/(user)/_components/home-sidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
|
||||
|
||||
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 type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
import { HomeAccountSelector } from './home-account-selector';
|
||||
|
||||
export function HomeSidebar(props: { workspace: UserWorkspace }) {
|
||||
const { workspace, user, accounts } = props.workspace;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent className={'h-16 justify-center'}>
|
||||
<If
|
||||
condition={featuresFlagConfig.enableTeamAccounts}
|
||||
fallback={<AppLogo className={'py-2'} />}
|
||||
>
|
||||
<HomeAccountSelector collapsed={false} accounts={accounts} />
|
||||
</If>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent className={`mt-5 h-[calc(100%-160px)] overflow-y-auto`}>
|
||||
<SidebarNavigation config={personalAccountNavigationConfig} />
|
||||
</SidebarContent>
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<ProfileAccountDropdownContainer
|
||||
collapsed={false}
|
||||
user={user}
|
||||
account={workspace}
|
||||
/>
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
16
apps/web/app/home/(user)/_components/user-notifications.tsx
Normal file
16
apps/web/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/web/app/home/(user)/_lib/server/load-user-workspace.ts
Normal file
46
apps/web/app/home/(user)/_lib/server/load-user-workspace.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
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(async () => {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const accountsPromise = shouldLoadAccounts
|
||||
? () => api.loadUserAccounts()
|
||||
: () => Promise.resolve([]);
|
||||
|
||||
const workspacePromise = api.getAccountWorkspace();
|
||||
const userPromise = client.auth.getUser();
|
||||
|
||||
const [accounts, workspace, userResult] = await Promise.all([
|
||||
accountsPromise(),
|
||||
workspacePromise,
|
||||
userPromise,
|
||||
]);
|
||||
|
||||
const user = userResult.data.user;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User is not logged in');
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
workspace,
|
||||
user,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
'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 { 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 [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 {
|
||||
const { checkoutToken } =
|
||||
await createPersonalAccountCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
} catch (e) {
|
||||
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,45 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-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((userId: string) => {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
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]);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||
import { createUserBillingService } from './user-billing.service';
|
||||
|
||||
/**
|
||||
* @name createPersonalAccountCheckoutSession
|
||||
* @description Creates a checkout session for a personal account.
|
||||
*/
|
||||
export const createPersonalAccountCheckoutSession = enhanceAction(
|
||||
async function (data) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
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 () => {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = createUserBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession();
|
||||
|
||||
return redirect(url);
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -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
apps/web/app/home/(user)/billing/error.tsx
Normal file
7
apps/web/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
apps/web/app/home/(user)/billing/layout.tsx
Normal file
15
apps/web/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;
|
||||
103
apps/web/app/home/(user)/billing/page.tsx
Normal file
103
apps/web/app/home/(user)/billing/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
BillingPortalCard,
|
||||
CurrentLifetimeOrderCard,
|
||||
CurrentSubscriptionCard,
|
||||
} from '@kit/billing-gateway/components';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
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';
|
||||
|
||||
// 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 client = getSupabaseServerComponentClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
if (auth.error) {
|
||||
redirect(auth.redirectTo);
|
||||
}
|
||||
|
||||
const [data, customerId] = await loadPersonalAccountBillingPageData(
|
||||
auth.data.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'common:billingTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<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={'mx-auto 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
apps/web/app/home/(user)/billing/return/page.tsx
Normal file
5
apps/web/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;
|
||||
48
apps/web/app/home/(user)/layout.tsx
Normal file
48
apps/web/app/home/(user)/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
|
||||
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';
|
||||
|
||||
const style = personalAccountNavigationConfig.style;
|
||||
|
||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||
const workspace = use(loadUserWorkspace());
|
||||
|
||||
return (
|
||||
<Page style={style}>
|
||||
<PageNavigation>
|
||||
<If condition={style === 'header'}>
|
||||
<HomeMenuNavigation workspace={workspace} />
|
||||
</If>
|
||||
|
||||
<If condition={style === 'sidebar'}>
|
||||
<HomeSidebar workspace={workspace} />
|
||||
</If>
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||
<div>
|
||||
<AppLogo />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<HomeMobileNavigation workspace={workspace} />
|
||||
</div>
|
||||
</PageMobileNavigation>
|
||||
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(UserHomeLayout);
|
||||
3
apps/web/app/home/(user)/loading.tsx
Normal file
3
apps/web/app/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
32
apps/web/app/home/(user)/page.tsx
Normal file
32
apps/web/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:homeTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:homeTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(UserHomePage);
|
||||
21
apps/web/app/home/(user)/settings/layout.tsx
Normal file
21
apps/web/app/home/(user)/settings/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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:accountTabLabel'} />}
|
||||
description={<Trans i18nKey={'account:accountTabDescription'} />}
|
||||
/>
|
||||
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(UserSettingsLayout);
|
||||
36
apps/web/app/home/(user)/settings/page.tsx
Normal file
36
apps/web/app/home/(user)/settings/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
|
||||
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';
|
||||
|
||||
const features = {
|
||||
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
|
||||
};
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback + `?next=${pathsConfig.app.accountHome}`,
|
||||
};
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('account:settingsTab');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function PersonalAccountSettingsPage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
|
||||
<PersonalAccountSettingsContainer features={features} paths={paths} />
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PersonalAccountSettingsPage);
|
||||
387
apps/web/app/home/[account]/_components/dashboard-demo.tsx
Normal file
387
apps/web/app/home/[account]/_components/dashboard-demo.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ArrowDown, ArrowUp, Menu } from 'lucide-react';
|
||||
import { Line, LineChart, ResponsiveContainer, XAxis } from 'recharts';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
export default function DashboardDemo() {
|
||||
const mrr = useMemo(() => generateDemoData(), []);
|
||||
const visitors = useMemo(() => generateDemoData(), []);
|
||||
const returningVisitors = useMemo(() => generateDemoData(), []);
|
||||
const churn = useMemo(() => generateDemoData(), []);
|
||||
const netRevenue = useMemo(() => generateDemoData(), []);
|
||||
const fees = useMemo(() => generateDemoData(), []);
|
||||
const newCustomers = useMemo(() => generateDemoData(), []);
|
||||
const tickets = useMemo(() => generateDemoData(), []);
|
||||
const activeUsers = useMemo(() => generateDemoData(), []);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-6 pb-36'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3' +
|
||||
' 2xl:grid-cols-4'
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Monthly Recurring Revenue</span>
|
||||
<Trend trend={'up'}>20%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{`$${mrr[1]}`}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={mrr[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Revenue</span>
|
||||
<Trend trend={'up'}>12%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'mb-4 flex items-center justify-between'}>
|
||||
<Figure>{`$${netRevenue[1]}`}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={netRevenue[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Fees</span>
|
||||
<Trend trend={'up'}>9%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{`$${fees[1]}`}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={fees[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>New Customers</span>
|
||||
<Trend trend={'down'}>-25%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{`${newCustomers[1]}`}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={newCustomers[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Visitors</span>
|
||||
<Trend trend={'down'}>-4.3%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{visitors[1]}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={visitors[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Returning Visitors</span>
|
||||
<Trend trend={'stale'}>10%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{returningVisitors[1]}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={returningVisitors[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Churn</span>
|
||||
<Trend trend={'up'}>-10%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{churn[1]}%</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={churn[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Support Tickets</span>
|
||||
<Trend trend={'up'}>-30%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{tickets[1]}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={tickets[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center justify-between'}>
|
||||
<span>Active Users</span>
|
||||
<Trend trend={'up'}>10%</Trend>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Figure>{activeUsers[1]}</Figure>
|
||||
</div>
|
||||
|
||||
<Chart data={activeUsers[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customers</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CustomersTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generateDemoData() {
|
||||
const today = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-us', {
|
||||
month: 'long',
|
||||
year: '2-digit',
|
||||
});
|
||||
|
||||
const data: { value: string; name: string }[] = [];
|
||||
|
||||
for (let n = 8; n > 0; n -= 1) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() - n, 1);
|
||||
|
||||
data.push({
|
||||
name: formatter.format(date),
|
||||
value: (Math.random() * 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
const lastValue = data[data.length - 1]?.value;
|
||||
|
||||
return [data, lastValue] as [typeof data, string];
|
||||
}
|
||||
|
||||
function Chart(
|
||||
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-36 py-2 duration-200 animate-in fade-in slide-in-from-left-4 slide-in-from-top-4'
|
||||
}
|
||||
>
|
||||
<ResponsiveContainer width={'100%'} height={'100%'}>
|
||||
<LineChart
|
||||
width={400}
|
||||
height={100}
|
||||
data={props.data}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 10,
|
||||
left: 10,
|
||||
bottom: 20,
|
||||
}}
|
||||
>
|
||||
<Line
|
||||
className={'text-primary'}
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
style={{ fontSize: 9 }}
|
||||
axisLine={false}
|
||||
tickSize={0}
|
||||
dataKey="name"
|
||||
height={15}
|
||||
dy={10}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>MRR</TableHead>
|
||||
<TableHead>Logins</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Pippin Oddo</TableCell>
|
||||
<TableCell>Pro</TableCell>
|
||||
<TableCell>$100.2</TableCell>
|
||||
<TableCell>920</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Väinö Pánfilo</TableCell>
|
||||
<TableCell>Basic</TableCell>
|
||||
<TableCell>$40.6</TableCell>
|
||||
<TableCell>300</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'stale'}>Possible Churn</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Giorgos Quinten</TableCell>
|
||||
<TableCell>Pro</TableCell>
|
||||
<TableCell>$2004.3</TableCell>
|
||||
<TableCell>1000</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Adhelm Otis</TableCell>
|
||||
<TableCell>Basic</TableCell>
|
||||
<TableCell>$0</TableCell>
|
||||
<TableCell>10</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'down'}>Churned</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeWithTrend(props: React.PropsWithChildren<{ trend: string }>) {
|
||||
const className = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return 'text-green-500';
|
||||
case 'down':
|
||||
return 'text-destructive';
|
||||
case 'stale':
|
||||
return 'text-orange-500';
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<Badge variant={'outline'}>
|
||||
<span className={className}>{props.children}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'font-heading text-4xl font-extrabold'}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trend(
|
||||
props: React.PropsWithChildren<{
|
||||
trend: 'up' | 'down' | 'stale';
|
||||
}>,
|
||||
) {
|
||||
const Icon = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return <ArrowUp className={'h-4 text-green-500'} />;
|
||||
case 'down':
|
||||
return <ArrowDown className={'h-4 text-destructive'} />;
|
||||
case 'stale':
|
||||
return <Menu className={'h-4 text-orange-500'} />;
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BadgeWithTrend trend={props.trend}>
|
||||
<span className={'flex items-center space-x-0.5'}>
|
||||
{Icon}
|
||||
<span>{props.children}</span>
|
||||
</span>
|
||||
</BadgeWithTrend>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const features = {
|
||||
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||
};
|
||||
|
||||
export function TeamAccountAccountsSelector(params: {
|
||||
selectedAccount: string;
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
}>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
selectedAccount={params.selectedAccount}
|
||||
accounts={params.accounts}
|
||||
collapsed={false}
|
||||
features={features}
|
||||
onAccountChange={(value) => {
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Home, LogOut, Menu } from 'lucide-react';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
|
||||
const features = {
|
||||
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
|
||||
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||
};
|
||||
|
||||
export const TeamAccountLayoutMobileNavigation = (
|
||||
props: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>,
|
||||
) => {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const Links = getTeamAccountSidebarConfig(props.account).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 (
|
||||
<DropdownLink
|
||||
key={item.path}
|
||||
Icon={item.Icon}
|
||||
path={item.path}
|
||||
label={item.label}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Menu className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||
<TeamAccountsModal />
|
||||
|
||||
{Links}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
function DropdownLink(
|
||||
props: React.PropsWithChildren<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={props.path}
|
||||
className={'flex h-12 w-full items-center space-x-2 px-3'}
|
||||
>
|
||||
{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-2'}
|
||||
onClick={props.onSignOut}
|
||||
>
|
||||
<LogOut className={'h-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:signOut'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamAccountsModal() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className={'flex h-12 w-full items-center space-x-2'}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Home className={'h-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:yourAccounts'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'common:yourAccounts'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={'py-16'}>
|
||||
<AccountSelector
|
||||
onAccountChange={(value) => {
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
accounts={[]}
|
||||
features={features}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
export function TeamAccountLayoutPageHeader(
|
||||
props: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
account: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<PageHeader title={props.title} description={props.description}>
|
||||
{props.children}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
|
||||
export function TeamAccountLayoutSidebarNavigation({
|
||||
account,
|
||||
}: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>) {
|
||||
const routes = getTeamAccountSidebarConfig(account).routes;
|
||||
|
||||
return (
|
||||
<>
|
||||
{routes.map((item, index) => {
|
||||
if ('divider' in item) {
|
||||
return <SidebarDivider key={index} />;
|
||||
}
|
||||
|
||||
if ('children' in item) {
|
||||
return (
|
||||
<SidebarGroup
|
||||
key={item.label}
|
||||
label={<Trans i18nKey={item.label} defaults={item.label} />}
|
||||
collapsible={item.collapsible}
|
||||
collapsed={item.collapsed}
|
||||
>
|
||||
{item.children.map((child) => {
|
||||
return (
|
||||
<SidebarItem
|
||||
key={child.path}
|
||||
end={child.end}
|
||||
path={child.path}
|
||||
Icon={child.Icon}
|
||||
>
|
||||
<Trans i18nKey={child.label} defaults={child.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
key={item.path}
|
||||
end={item.end}
|
||||
path={item.path}
|
||||
Icon={item.Icon}
|
||||
>
|
||||
<Trans i18nKey={item.label} defaults={item.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { User } from '@supabase/supabase-js';
|
||||
|
||||
import { ArrowLeftCircle, ArrowRightCircle } from 'lucide-react';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { ProfileAccountDropdownContainer } from '~/components//personal-account-dropdown-container';
|
||||
|
||||
import { TeamAccountAccountsSelector } from '../_components/team-account-accounts-selector';
|
||||
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
|
||||
|
||||
type AccountModel = {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
export function TeamAccountLayoutSidebar(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
collapsed: boolean;
|
||||
user: User;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar collapsed={props.collapsed}>
|
||||
{({ collapsed, setCollapsed }) => (
|
||||
<SidebarContainer
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
account={props.account}
|
||||
accounts={props.accounts}
|
||||
user={props.user}
|
||||
/>
|
||||
)}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContainer(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
collapsible?: boolean;
|
||||
user: User;
|
||||
}) {
|
||||
const { account, accounts } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarContent className={'mt-4 justify-center'}>
|
||||
<TeamAccountAccountsSelector
|
||||
selectedAccount={account}
|
||||
accounts={accounts}
|
||||
/>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent className={`mt-5 h-[calc(100%-160px)] overflow-y-auto`}>
|
||||
<TeamAccountLayoutSidebarNavigation account={account} />
|
||||
</SidebarContent>
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<div className={'flex space-x-2'}>
|
||||
<ProfileAccountDropdownContainer
|
||||
user={props.user}
|
||||
collapsed={props.collapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<If condition={props.collapsible}>
|
||||
<AppSidebarFooterMenu
|
||||
collapsed={props.collapsed}
|
||||
setCollapsed={props.setCollapsed}
|
||||
/>
|
||||
</If>
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSidebarFooterMenu(props: {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<CollapsibleButton
|
||||
collapsed={props.collapsed}
|
||||
onClick={props.setCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleButton({
|
||||
collapsed,
|
||||
onClick,
|
||||
}: React.PropsWithChildren<{
|
||||
collapsed: boolean;
|
||||
onClick: (collapsed: boolean) => void;
|
||||
}>) {
|
||||
const className = cn(
|
||||
`bg-background absolute -right-[10.5px] bottom-4 cursor-pointer block`,
|
||||
);
|
||||
|
||||
const iconClassName = 'bg-background text-muted-foreground h-5 w-5';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className={className}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => onClick(!collapsed)}
|
||||
>
|
||||
<ArrowRightCircle
|
||||
className={cn(iconClassName, {
|
||||
hidden: !collapsed,
|
||||
})}
|
||||
/>
|
||||
|
||||
<ArrowLeftCircle
|
||||
className={cn(iconClassName, {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent sideOffset={20}>
|
||||
<Trans
|
||||
i18nKey={
|
||||
collapsed ? 'common:expandSidebar' : 'common:collapseSidebar'
|
||||
}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BorderedNavigationMenu,
|
||||
BorderedNavigationMenuItem,
|
||||
} from '@kit/ui/bordered-navigation-menu';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader';
|
||||
import { TeamAccountNotifications } from './team-account-notifications';
|
||||
|
||||
export function TeamAccountNavigationMenu(props: {
|
||||
workspace: TeamAccountWorkspace;
|
||||
}) {
|
||||
const { account, user, accounts } = props.workspace;
|
||||
|
||||
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
|
||||
Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon?: React.ReactNode;
|
||||
end?: boolean | undefined;
|
||||
}>
|
||||
>((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-4'}>
|
||||
<AppLogo />
|
||||
|
||||
<BorderedNavigationMenu>
|
||||
{routes.map((route) => (
|
||||
<BorderedNavigationMenuItem {...route} key={route.path} />
|
||||
))}
|
||||
</BorderedNavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-4'}>
|
||||
<TeamAccountAccountsSelector
|
||||
selectedAccount={account.id}
|
||||
accounts={accounts.map((account) => ({
|
||||
label: account.name,
|
||||
value: account.id,
|
||||
image: account.picture_url,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TeamAccountNotifications accountId={account.id} userId={user.id} />
|
||||
|
||||
<ProfileAccountDropdownContainer
|
||||
collapsed={true}
|
||||
user={user}
|
||||
account={account}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NotificationsPopover } from '@kit/notifications/components';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
|
||||
export function TeamAccountNotifications(params: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
}) {
|
||||
if (!featuresFlagConfig.enableNotifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationsPopover
|
||||
accountIds={[params.userId, params.accountId]}
|
||||
realtime={featuresFlagConfig.realtimeNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-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);
|
||||
|
||||
export const loadTeamAccountBillingPage = cache((accountId: string) => {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const data =
|
||||
BILLING_MODE === 'subscription'
|
||||
? api.getSubscription(accountId)
|
||||
: api.getOrder(accountId);
|
||||
|
||||
const customerId = api.getCustomerId(accountId);
|
||||
|
||||
return Promise.all([data, customerId]);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export type TeamAccountWorkspace = Awaited<
|
||||
ReturnType<typeof loadTeamWorkspace>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Load the account workspace data.
|
||||
* We place this function into a separate file so it can be reused in multiple places across the server components.
|
||||
*
|
||||
* This function is used in the layout component for the account workspace.
|
||||
* It is cached so that the data is only fetched once per request.
|
||||
*
|
||||
* @param accountSlug
|
||||
*/
|
||||
export const loadTeamWorkspace = cache(async (accountSlug: string) => {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const workspace = await api.getAccountWorkspace(accountSlug);
|
||||
|
||||
if (workspace.error) {
|
||||
throw workspace.error;
|
||||
}
|
||||
|
||||
const account = workspace.data.account;
|
||||
|
||||
// we cannot find any record for the selected account
|
||||
// so we redirect the user to the home page
|
||||
if (!account) {
|
||||
return redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
return workspace.data;
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { PlanPicker } from '@kit/billing-gateway/components';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
import { createTeamAccountCheckoutSession } from '../_lib/server/server-actions';
|
||||
|
||||
const EmbeddedCheckout = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return {
|
||||
default: EmbeddedCheckout,
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function TeamAccountCheckoutForm(params: {
|
||||
accountId: string;
|
||||
customerId: string | null | undefined;
|
||||
}) {
|
||||
const routeParams = useParams();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
onClose={() => setCheckoutToken(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// only allow trial if the user is not already a customer
|
||||
const canStartTrial = !params.customerId;
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'billing:manageTeamPlan'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'billing:manageTeamPlanDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
canStartTrial={canStartTrial}
|
||||
onSubmit={({ planId, productId }) => {
|
||||
startTransition(async () => {
|
||||
const slug = routeParams.account as string;
|
||||
|
||||
const { checkoutToken } = await createTeamAccountCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
slug,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TeamBillingPortalSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
slug: z.string().min(1),
|
||||
});
|
||||
|
||||
export const TeamCheckoutSchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
planId: z.string().min(1),
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
// billing imports
|
||||
import {
|
||||
TeamBillingPortalSchema,
|
||||
TeamCheckoutSchema,
|
||||
} from '../schema/team-billing.schema';
|
||||
import { createTeamBillingService } from './team-billing.service';
|
||||
|
||||
/**
|
||||
* @name createTeamAccountCheckoutSession
|
||||
* @description Creates a checkout session for a team account.
|
||||
*/
|
||||
export const createTeamAccountCheckoutSession = enhanceAction(
|
||||
(data) => {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
return service.createCheckout(data);
|
||||
},
|
||||
{
|
||||
schema: TeamCheckoutSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a Billing Session Portal and redirects the user to the
|
||||
* provider's hosted instance
|
||||
*/
|
||||
export const createBillingPortalSession = enhanceAction(
|
||||
async (formData: FormData) => {
|
||||
const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = createTeamBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession(params);
|
||||
|
||||
return redirect(url);
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LineItemSchema } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
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 { TeamCheckoutSchema } from '../schema/team-billing.schema';
|
||||
|
||||
export function createTeamBillingService(client: SupabaseClient<Database>) {
|
||||
return new TeamBillingService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name TeamBillingService
|
||||
* @description Service for managing billing for team accounts.
|
||||
*/
|
||||
class TeamBillingService {
|
||||
private readonly namespace = 'billing.team-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name createCheckout
|
||||
* @description Creates a checkout session for a Team account
|
||||
*/
|
||||
async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) {
|
||||
// we require the user to be authenticated
|
||||
const { data: user } = await requireUser(this.client);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const accountId = params.accountId;
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
userId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Requested checkout session. Processing...`);
|
||||
|
||||
const api = createTeamAccountsApi(this.client);
|
||||
|
||||
// verify permissions to manage billing
|
||||
const hasPermission = await api.hasPermission({
|
||||
userId,
|
||||
accountId,
|
||||
permission: 'billing.manage',
|
||||
});
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
logger.warn(
|
||||
ctx,
|
||||
`User without permissions attempted to create checkout.`,
|
||||
);
|
||||
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
// here we have confirmed that the user has permission to manage billing for the account
|
||||
// so we go on and create a checkout session
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
|
||||
// retrieve the plan from the configuration
|
||||
// so we can assign the correct checkout data
|
||||
const { plan, product } = getPlanDetails(params.productId, params.planId);
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
const customerEmail = user.email;
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
||||
|
||||
// get variant quantities
|
||||
// useful for setting an initial quantity value for certain line items
|
||||
// such as per seat
|
||||
const variantQuantities = await this.getVariantQuantities(
|
||||
plan.lineItems,
|
||||
accountId,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
planId: plan.id,
|
||||
},
|
||||
`Creating checkout session...`,
|
||||
);
|
||||
|
||||
try {
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
plan,
|
||||
returnUrl,
|
||||
customerEmail,
|
||||
customerId,
|
||||
variantQuantities,
|
||||
enableDiscountField: product.enableDiscountField,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Error creating the checkout session`,
|
||||
);
|
||||
|
||||
throw new Error(`Checkout not created`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Creates a new billing portal session for a team account
|
||||
* @param accountId
|
||||
* @param slug
|
||||
*/
|
||||
async createBillingPortalSession({
|
||||
accountId,
|
||||
slug,
|
||||
}: {
|
||||
accountId: string;
|
||||
slug: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Billing portal session requested. Processing...`,
|
||||
);
|
||||
|
||||
const { data: user, error } = await requireUser(client);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// we require the user to have permissions to manage billing for the account
|
||||
const hasPermission = await api.hasPermission({
|
||||
userId,
|
||||
accountId,
|
||||
permission: 'billing.manage',
|
||||
});
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
logger.warn(
|
||||
{
|
||||
userId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`User without permissions attempted to create billing portal session.`,
|
||||
);
|
||||
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
customerId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Creating billing portal session...`,
|
||||
);
|
||||
|
||||
// get the billing gateway provider
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
|
||||
try {
|
||||
const returnUrl = getBillingPortalReturnUrl(slug);
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
// redirect the user to the billing portal
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
userId,
|
||||
customerId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Billing Portal session was not created`,
|
||||
);
|
||||
|
||||
throw new Error(`Error creating Billing Portal`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves variant quantities for line items.
|
||||
*/
|
||||
private async getVariantQuantities(
|
||||
lineItems: z.infer<typeof LineItemSchema>[],
|
||||
accountId: string,
|
||||
) {
|
||||
const variantQuantities: Array<{
|
||||
quantity: number;
|
||||
variantId: string;
|
||||
}> = [];
|
||||
|
||||
for (const lineItem of lineItems) {
|
||||
// check if the line item is a per seat type
|
||||
const isPerSeat = lineItem.type === 'per_seat';
|
||||
|
||||
if (isPerSeat) {
|
||||
// get the current number of members in the account
|
||||
const quantity = await this.getCurrentMembersCount(accountId);
|
||||
|
||||
const item = {
|
||||
quantity,
|
||||
variantId: lineItem.id,
|
||||
};
|
||||
|
||||
variantQuantities.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// set initial quantity for the line items
|
||||
return variantQuantities;
|
||||
}
|
||||
|
||||
private async getCurrentMembersCount(accountId: string) {
|
||||
const api = createTeamAccountsApi(this.client);
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
return count ?? 1;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
accountId,
|
||||
error,
|
||||
name: `billing.checkout`,
|
||||
},
|
||||
`Encountered an error while fetching the number of existing seats`,
|
||||
);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl(accountSlug: string) {
|
||||
return getAccountUrl(pathsConfig.app.accountBillingReturn, accountSlug);
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl(accountSlug: string) {
|
||||
return getAccountUrl(pathsConfig.app.accountBilling, accountSlug);
|
||||
}
|
||||
|
||||
function getAccountUrl(path: string, slug: string) {
|
||||
return new URL(path, appConfig.url).toString().replace('[account]', slug);
|
||||
}
|
||||
|
||||
function getPlanDetails(productId: string, planId: string) {
|
||||
const product = billingConfig.products.find(
|
||||
(product) => product.id === productId,
|
||||
);
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const plan = product?.plans.find((plan) => plan.id === planId);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
return { plan, product };
|
||||
}
|
||||
50
apps/web/app/home/[account]/billing/error.tsx
Normal file
50
apps/web/app/home/[account]/billing/error.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function BillingErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useCaptureException(error);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:billingTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing:planPickerAlertErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing:planPickerAlertErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Button variant={'outline'} onClick={reset}>
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
apps/web/app/home/[account]/billing/layout.tsx
Normal file
15
apps/web/app/home/[account]/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function TeamAccountBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enableTeamAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default TeamAccountBillingLayout;
|
||||
135
apps/web/app/home/[account]/billing/page.tsx
Normal file
135
apps/web/app/home/[account]/billing/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import {
|
||||
BillingPortalCard,
|
||||
CurrentLifetimeOrderCard,
|
||||
CurrentSubscriptionCard,
|
||||
} from '@kit/billing-gateway/components';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
|
||||
import { loadTeamAccountBillingPage } from '../_lib/server/team-account-billing-page.loader';
|
||||
import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader';
|
||||
import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form';
|
||||
import { createBillingPortalSession } from './_lib/server/server-actions';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('teams:billing.pageTitle');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
async function TeamAccountBillingPage({ params }: Params) {
|
||||
const workspace = await loadTeamWorkspace(params.account);
|
||||
const accountId = workspace.account.id;
|
||||
|
||||
const [data, customerId] = await loadTeamAccountBillingPage(accountId);
|
||||
|
||||
const canManageBilling =
|
||||
workspace.account.permissions.includes('billing.manage');
|
||||
|
||||
const Checkout = () => {
|
||||
if (!canManageBilling) {
|
||||
return <CannotManageBillingAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamAccountCheckoutForm customerId={customerId} accountId={accountId} />
|
||||
);
|
||||
};
|
||||
|
||||
const BillingPortal = () => {
|
||||
if (!canManageBilling || !customerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={createBillingPortalSession}>
|
||||
<input type="hidden" name={'accountId'} value={accountId} />
|
||||
<input type="hidden" name={'slug'} value={params.account} />
|
||||
|
||||
<BillingPortalCard />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={params.account}
|
||||
title={<Trans i18nKey={'common:billingTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div
|
||||
className={cn(`flex w-full flex-col space-y-4`, {
|
||||
'mx-auto max-w-2xl': data,
|
||||
})}
|
||||
>
|
||||
<If
|
||||
condition={data}
|
||||
fallback={
|
||||
<div>
|
||||
<Checkout />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => {
|
||||
if ('active' in data) {
|
||||
return (
|
||||
<CurrentSubscriptionCard
|
||||
subscription={data}
|
||||
config={billingConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentLifetimeOrderCard order={data} config={billingConfig} />
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<BillingPortal />
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamAccountBillingPage);
|
||||
|
||||
function CannotManageBillingAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'billing:cannotManageBillingAlertTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'billing:cannotManageBillingAlertDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
118
apps/web/app/home/[account]/billing/return/page.tsx
Normal file
118
apps/web/app/home/[account]/billing/return/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { BillingSessionStatus } from '@kit/billing-gateway/components';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface SessionPageProps {
|
||||
searchParams: {
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const LazyEmbeddedCheckout = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return EmbeddedCheckout;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
|
||||
const sessionId = searchParams.session_id;
|
||||
|
||||
if (!sessionId) {
|
||||
redirect('../');
|
||||
}
|
||||
|
||||
const { customerEmail, checkoutToken } = await loadCheckoutSession(sessionId);
|
||||
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<LazyEmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
||||
<BillingSessionStatus
|
||||
onRedirect={onRedirect}
|
||||
customerEmail={customerEmail ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BlurryBackdrop />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ReturnCheckoutSessionPage);
|
||||
|
||||
function BlurryBackdrop() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCheckoutSession(sessionId: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
const { error } = await requireUser(client);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const gateway = await getBillingGatewayProvider(client);
|
||||
|
||||
const session = await gateway.retrieveCheckoutSession({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const checkoutToken = session.isSessionOpen ? session.checkoutToken : null;
|
||||
|
||||
// otherwise - we show the user the return page
|
||||
// and display the details of the session
|
||||
return {
|
||||
status: session.status,
|
||||
customerEmail: session.customer.email,
|
||||
checkoutToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidates the layout to update cached pages
|
||||
* and redirects back to the home page.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async function onRedirect() {
|
||||
'use server';
|
||||
|
||||
// revalidate the home page to update cached pages
|
||||
// which may have changed due to the billing session
|
||||
revalidatePath('/home', 'layout');
|
||||
|
||||
// redirect back to billing page
|
||||
redirect('../billing');
|
||||
}
|
||||
66
apps/web/app/home/[account]/layout.tsx
Normal file
66
apps/web/app/home/[account]/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountLayoutMobileNavigation } from './_components/team-account-layout-mobile-navigation';
|
||||
import { TeamAccountLayoutSidebar } from './_components/team-account-layout-sidebar';
|
||||
import { TeamAccountNavigationMenu } from './_components/team-account-navigation-menu';
|
||||
import { loadTeamWorkspace } from './_lib/server/team-account-workspace.loader';
|
||||
|
||||
interface Params {
|
||||
account: string;
|
||||
}
|
||||
|
||||
function TeamWorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: React.PropsWithChildren<{
|
||||
params: Params;
|
||||
}>) {
|
||||
const data = use(loadTeamWorkspace(params.account));
|
||||
const style = getTeamAccountSidebarConfig(params.account).style;
|
||||
|
||||
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
|
||||
label: name,
|
||||
value: slug,
|
||||
image: picture_url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Page style={style}>
|
||||
<PageNavigation>
|
||||
<If condition={style === 'sidebar'}>
|
||||
<TeamAccountLayoutSidebar
|
||||
collapsed={false}
|
||||
account={params.account}
|
||||
accounts={accounts}
|
||||
user={data.user}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={style === 'header'}>
|
||||
<TeamAccountNavigationMenu workspace={data} />
|
||||
</If>
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||
<AppLogo />
|
||||
|
||||
<div className={'flex space-x-4'}>
|
||||
<TeamAccountLayoutMobileNavigation account={params.account} />
|
||||
</div>
|
||||
</PageMobileNavigation>
|
||||
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamWorkspaceLayout);
|
||||
3
apps/web/app/home/[account]/loading.tsx
Normal file
3
apps/web/app/home/[account]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '~/lib/database.types';
|
||||
|
||||
import { loadTeamWorkspace } from '../../../_lib/server/team-account-workspace.loader';
|
||||
|
||||
/**
|
||||
* Load data for the members page
|
||||
* @param client
|
||||
* @param slug
|
||||
*/
|
||||
export async function loadMembersPageData(
|
||||
client: SupabaseClient<Database>,
|
||||
slug: string,
|
||||
) {
|
||||
return Promise.all([
|
||||
loadTeamWorkspace(slug),
|
||||
loadAccountMembers(client, slug),
|
||||
loadInvitations(client, slug),
|
||||
loadUser(client),
|
||||
canAddMember,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name canAddMember
|
||||
* @description Check if the current user can add a member to the account
|
||||
*
|
||||
* This needs additional logic to determine if the user can add a member to the account
|
||||
* Please implement the logic and return a boolean value
|
||||
*
|
||||
* The same check needs to be added when creating an invitation
|
||||
*
|
||||
*/
|
||||
async function canAddMember() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async function loadUser(client: SupabaseClient<Database>) {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load account members
|
||||
* @param client
|
||||
* @param account
|
||||
*/
|
||||
async function loadAccountMembers(
|
||||
client: SupabaseClient<Database>,
|
||||
account: string,
|
||||
) {
|
||||
const { data, error } = await client.rpc('get_account_members', {
|
||||
account_slug: account,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load account invitations
|
||||
* @param client
|
||||
* @param account
|
||||
*/
|
||||
async function loadInvitations(
|
||||
client: SupabaseClient<Database>,
|
||||
account: string,
|
||||
) {
|
||||
const { data, error } = await client.rpc('get_account_invitations', {
|
||||
account_slug: account,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
135
apps/web/app/home/[account]/members/page.tsx
Normal file
135
apps/web/app/home/[account]/members/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import {
|
||||
AccountInvitationsTable,
|
||||
AccountMembersTable,
|
||||
InviteMembersDialogContainer,
|
||||
} from '@kit/team-accounts/components';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
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 { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
|
||||
import { loadMembersPageData } from './_lib/server/members-page.loader';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('teams:members.pageTitle');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
async function TeamAccountMembersPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const [{ account }, members, invitations, user, canAddMember] =
|
||||
await loadMembersPageData(client, params.account);
|
||||
|
||||
const canManageRoles = account.permissions.includes('roles.manage');
|
||||
const canManageInvitations = account.permissions.includes('invites.manage');
|
||||
|
||||
const isPrimaryOwner = account.primary_owner_user_id === user.id;
|
||||
const currentUserRoleHierarchy = account.role_hierarchy_level;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamAccountLayoutPageHeader
|
||||
title={<Trans i18nKey={'common:membersTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:membersTabDescription'} />}
|
||||
account={account.slug}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex w-full max-w-4xl flex-col space-y-6 pb-32'}>
|
||||
<Card>
|
||||
<CardHeader className={'flex flex-row justify-between'}>
|
||||
<div className={'flex flex-col space-y-1.5'}>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'common:membersTabLabel'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'common:membersTabDescription'} />
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<If condition={canManageInvitations && canAddMember}>
|
||||
<InviteMembersDialogContainer
|
||||
userRoleHierarchy={currentUserRoleHierarchy}
|
||||
accountSlug={account.slug}
|
||||
>
|
||||
<Button size={'sm'} data-test={'invite-members-form-trigger'}>
|
||||
<PlusCircle className={'mr-2 w-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'teams:inviteMembersButton'} />
|
||||
</span>
|
||||
</Button>
|
||||
</InviteMembersDialogContainer>
|
||||
</If>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountMembersTable
|
||||
userRoleHierarchy={currentUserRoleHierarchy}
|
||||
currentUserId={user.id}
|
||||
currentAccountId={account.id}
|
||||
members={members}
|
||||
isPrimaryOwner={isPrimaryOwner}
|
||||
canManageRoles={canManageRoles}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className={'flex flex-row justify-between'}>
|
||||
<div className={'flex flex-col space-y-1.5'}>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'teams:pendingInvitesHeading'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'teams:pendingInvitesDescription'} />
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountInvitationsTable
|
||||
permissions={{
|
||||
canUpdateInvitation: canManageRoles,
|
||||
canRemoveInvitation: canManageRoles,
|
||||
currentUserRoleHierarchy,
|
||||
}}
|
||||
invitations={invitations}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamAccountMembersPage);
|
||||
70
apps/web/app/home/[account]/page.tsx
Normal file
70
apps/web/app/home/[account]/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import loadDynamic from 'next/dynamic';
|
||||
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
|
||||
|
||||
interface Params {
|
||||
account: string;
|
||||
}
|
||||
|
||||
const DashboardDemo = loadDynamic(
|
||||
() => import('./_components/dashboard-demo'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={
|
||||
'flex h-full flex-1 flex-col items-center justify-center space-y-4' +
|
||||
' py-24'
|
||||
}
|
||||
>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common:loading'} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('teams:home.pageTitle');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function TeamAccountHomePage({ params }: { params: Params }) {
|
||||
return (
|
||||
<>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={params.account}
|
||||
title={<Trans i18nKey={'common:dashboardTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:dashboardTabDescription'} />}
|
||||
>
|
||||
<Button size={'sm'}>
|
||||
<PlusCircle className={'mr-1 h-4'} />
|
||||
<span>Add Widget</span>
|
||||
</Button>
|
||||
</TeamAccountLayoutPageHeader>
|
||||
|
||||
<PageBody>
|
||||
<DashboardDemo />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(TeamAccountHomePage);
|
||||
59
apps/web/app/home/[account]/settings/page.tsx
Normal file
59
apps/web/app/home/[account]/settings/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { TeamAccountSettingsContainer } from '@kit/team-accounts/components';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
|
||||
import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('teams:settings:pageTitle');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
const paths = {
|
||||
teamAccountSettings: pathsConfig.app.accountSettings,
|
||||
};
|
||||
|
||||
async function TeamAccountSettingsPage(props: Props) {
|
||||
const data = await loadTeamWorkspace(props.params.account);
|
||||
|
||||
const account = {
|
||||
id: data.account.id,
|
||||
name: data.account.name,
|
||||
pictureUrl: data.account.picture_url,
|
||||
slug: data.account.slug,
|
||||
primaryOwnerUserId: data.account.primary_owner_user_id,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account.slug}
|
||||
title={<Trans i18nKey={'teams:settings.pageTitle'} />}
|
||||
description={<Trans i18nKey={'teams:settings.pageDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex max-w-2xl flex-1 flex-col'}>
|
||||
<TeamAccountSettingsContainer account={account} paths={paths} />
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAccountSettingsPage;
|
||||
3
apps/web/app/home/loading.tsx
Normal file
3
apps/web/app/home/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
Reference in New Issue
Block a user