This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

36
apps/web/.env.development Normal file
View File

@@ -0,0 +1,36 @@
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_PRODUCT_NAME=Makerkit
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true
EMAIL_SENDER=test@makerkit.dev
EMAIL_PORT=54325
EMAIL_HOST=localhost
EMAIL_TLS=false
EMAIL_USER=user
EMAIL_PASSWORD=password
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# FEATURE FLAGS
NEXT_PUBLIC_ENABLE_ACCOUNT_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
NEXT_PUBLIC_ENABLE_ORGANIZATION_DELETION=true
NEXT_PUBLIC_ENABLE_ORGANIZATION_INVITATIONS=true
NEXT_PUBLIC_ENABLE_ORGANIZATION_BILLING=true
# LOCALES
NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales
# PATHS
# Please make sure to update these in the app's paths configuration as well
SIGN_IN_PATH=/auth/sign-in
SIGN_UP_PATH=/auth/sign-up
ORGANIZATION_ACCOUNTS_PATH=/home
INVITATION_PAGE_PATH=/invite

28
apps/web/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

View File

@@ -0,0 +1,12 @@
'use server';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
/**
* Refreshes the user session on the server when updating the user profile.
*/
export async function refreshSessionAction() {
const supabase = getSupabaseServerActionClient();
await supabase.auth.refreshSession();
}

View File

@@ -0,0 +1,19 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<PageHeader
title={<Trans i18nKey={'common:yourAccountTabLabel'} />}
description={'Manage your account settings'}
/>
<PageBody>{props.children}</PageBody>
</>
);
}
export default withI18n(UserSettingsLayout);

View File

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

View File

@@ -0,0 +1,26 @@
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
function PersonalAccountSettingsPage() {
return (
<div
className={
'container mx-auto flex max-w-2xl flex-1 flex-col items-center'
}
>
<PersonalAccountSettingsContainer
features={{
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
}}
paths={{
callback: pathsConfig.auth.callback,
}}
/>
</div>
);
}
export default withI18n(PersonalAccountSettingsPage);

View File

@@ -0,0 +1,15 @@
import { notFound } from 'next/navigation';
import featureFlagsConfig from '~/config/feature-flags.config';
function UserBillingLayout(props: React.PropsWithChildren) {
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
if (!isEnabled) {
notFound();
}
return <>{props.children}</>;
}
export default UserBillingLayout;

View File

@@ -0,0 +1,19 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
function PersonalAccountBillingPage() {
return (
<>
<PageHeader
title={<Trans i18nKey={'common:billingTabLabel'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />}
/>
<PageBody></PageBody>
</>
);
}
export default withI18n(PersonalAccountBillingPage);

View File

@@ -0,0 +1,10 @@
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Page } from '@kit/ui/page';
function UserHomeLayout({ children }: React.PropsWithChildren) {
return <Page sidebar={<HomeSidebar />}>{children}</Page>;
}
export default withI18n(UserHomeLayout);

View File

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

View File

@@ -0,0 +1,24 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
function UserHomePage() {
return (
<>
<PageHeader
title={<Trans i18nKey={'common:homeTabLabel'} defaults={'Home'} />}
description={
<Trans
i18nKey={'common:homeTabDescription'}
defaults={'Welcome to your Home Page'}
/>
}
/>
<PageBody></PageBody>
</>
);
}
export default withI18n(UserHomePage);

View File

@@ -0,0 +1,24 @@
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
import { PageHeader } from '@kit/ui/page';
export function AppHeader({
children,
title,
description,
account,
}: React.PropsWithChildren<{
title: string | React.ReactNode;
description?: string | React.ReactNode;
account: string;
}>) {
return (
<PageHeader
title={title}
description={description}
mobileNavigation={<MobileAppNavigation slug={account} />}
>
{children}
</PageHeader>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
import { Trans } from '@kit/ui/trans';
export function AppSidebarNavigation({
account,
}: React.PropsWithChildren<{
account: string;
}>) {
return (
<>
{getOrganizationAccountSidebarConfig(account).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>
);
},
)}
</>
);
}
export default AppSidebarNavigation;

View File

@@ -0,0 +1,155 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
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 { AppSidebarNavigation } from './app-sidebar-navigation';
type AccountModel = {
label: string | null;
value: string | null;
image: string | null;
};
const features = {
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,
};
export function AppSidebar(props: {
account: string;
accounts: AccountModel[];
collapsed: boolean;
}) {
return (
<Sidebar collapsed={props.collapsed}>
{({ collapsed, setCollapsed }) => (
<SidebarContainer
collapsed={collapsed}
setCollapsed={setCollapsed}
account={props.account}
accounts={props.accounts}
/>
)}
</Sidebar>
);
}
function SidebarContainer(props: {
account: string;
accounts: AccountModel[];
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}) {
const { account, accounts } = props;
const router = useRouter();
return (
<>
<SidebarContent className={'my-4'}>
<AccountSelector
selectedAccount={account}
accounts={accounts}
collapsed={props.collapsed}
features={features}
onAccountChange={(value) => {
const path = value
? pathsConfig.app.accountHome.replace('[account]', value)
: pathsConfig.app.home;
router.replace(path);
}}
/>
</SidebarContent>
<SidebarContent className={`h-[calc(100%-160px)] overflow-y-auto`}>
<AppSidebarNavigation account={account} />
</SidebarContent>
<div className={'absolute bottom-4 left-0 w-full'}>
<SidebarContent>
<ProfileDropdownContainer collapsed={props.collapsed} />
<AppSidebarFooterMenu
collapsed={props.collapsed}
setCollapsed={props.setCollapsed}
/>
</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-gray-300 dark:text-gray-600 h-5 w-5';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
className={className}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => onClick(!collapsed)}
>
<ArrowRightCircleIcon
className={cn(iconClassName, {
hidden: !collapsed,
})}
/>
<ArrowLeftCircleIcon
className={cn(iconClassName, {
hidden: collapsed,
})}
/>
</TooltipTrigger>
<TooltipContent sideOffset={20}>
<Trans
i18nKey={
collapsed ? 'common:expandSidebar' : 'common:collapseSidebar'
}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,349 @@
'use client';
import { useMemo } from 'react';
import { ArrowDownIcon, ArrowUpIcon, MenuIcon } 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>Monthly Recurring Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${mrr[1]}`}</Figure>
<Trend trend={'up'}>20%</Trend>
</div>
<Chart data={mrr[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${netRevenue[1]}`}</Figure>
<Trend trend={'up'}>12%</Trend>
</div>
<Chart data={netRevenue[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fees</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${fees[1]}`}</Figure>
<Trend trend={'up'}>9%</Trend>
</div>
<Chart data={fees[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>New Customers</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`${newCustomers[1]}`}</Figure>
<Trend trend={'down'}>-25%</Trend>
</div>
<Chart data={newCustomers[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Visitors</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{visitors[1]}</Figure>
<Trend trend={'down'}>-4.3%</Trend>
</div>
<Chart data={visitors[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Returning Visitors</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{returningVisitors[1]}</Figure>
<Trend trend={'stale'}>10%</Trend>
</div>
<Chart data={returningVisitors[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Churn</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{churn[1]}%</Figure>
<Trend trend={'up'}>-10%</Trend>
</div>
<Chart data={churn[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Support Tickets</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{tickets[1]}</Figure>
<Trend trend={'up'}>-30%</Trend>
</div>
<Chart data={tickets[0]} />
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<CardTitle>Active Users</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{activeUsers[1]}</Figure>
<Trend trend={'up'}>10%</Trend>
</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),
});
}
return [data, data[data.length - 1].value] as [typeof data, string];
}
function Chart(
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
) {
return (
<div className={'h-36'}>
<ResponsiveContainer width={'100%'} height={'100%'}>
<LineChart width={400} height={100} data={props.data}>
<Line
className={'text-primary'}
type="monotone"
dataKey="value"
stroke="currentColor"
strokeWidth={2.5}
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={'text-4xl font-bold'}>{props.children}</div>;
}
function Trend(
props: React.PropsWithChildren<{
trend: 'up' | 'down' | 'stale';
}>,
) {
const Icon = useMemo(() => {
switch (props.trend) {
case 'up':
return <ArrowUpIcon className={'h-4 text-green-500'} />;
case 'down':
return <ArrowDownIcon className={'h-4 text-destructive'} />;
case 'stale':
return <MenuIcon 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>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
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';
export const MobileAppNavigation = (
props: React.PropsWithChildren<{
slug: string;
}>,
) => {
const signOut = useSignOut();
const Links = getOrganizationAccountSidebarConfig(props.slug).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>
<MenuIcon className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<OrganizationsModal />
{Links}
<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}
>
<LogOutIcon className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}
function OrganizationsModal() {
const router = useRouter();
return (
<Dialog>
<DialogTrigger>
<DropdownMenuItem
className={'h-12'}
onSelect={(e) => e.preventDefault()}
>
<button className={'flex items-center space-x-4'}>
<HomeIcon className={'h-6'} />
<span>
<Trans i18nKey={'common:yourOrganizations'} />
</span>
</button>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'common:yourOrganizations'} />
</DialogTitle>
</DialogHeader>
<div className={'flex flex-col space-y-6 py-4'}>
<AccountSelector
onAccountChange={(value) => {
const path = value
? pathsConfig.app.accountHome.replace('[account]', value)
: pathsConfig.app.home;
router.replace(path);
}}
accounts={[]}
features={{
enableOrganizationAccounts:
featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation:
featureFlagsConfig.enableOrganizationCreation,
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
import { cache } from 'react';
import { redirect } from 'next/navigation';
import 'server-only';
import pathsConfig from '~/config/paths.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
/**
* Load the organization 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 organization workspace.
* It is cached so that the data is only fetched once per request.
*
* @param accountSlug
*/
export const loadOrganizationWorkspace = cache(async (accountSlug: string) => {
const client = getSupabaseServerComponentClient();
const accountPromise = client.rpc('organization_account_workspace', {
account_slug: accountSlug,
});
const accountsPromise = client.from('user_accounts').select('*');
const userSessionPromise = client.auth.getSession();
const [accountResult, accountsResult, sessionResult] = await Promise.all([
accountPromise,
accountsPromise,
userSessionPromise,
]);
if (accountResult.error) {
throw accountResult.error;
}
// we cannot find any record for the selected organization
// so we redirect the user to the home page
if (!accountResult.data.length) {
return redirect(pathsConfig.app.home);
}
const accountData = accountResult.data[0];
if (!accountData) {
return redirect(pathsConfig.app.home);
}
if (accountsResult.error) {
throw accountsResult.error;
}
if (sessionResult.error ?? !sessionResult.data.session?.user) {
throw new Error('User session not found');
}
return {
account: accountData,
accounts: accountsResult.data,
user: sessionResult.data.session.user,
};
});

View File

@@ -0,0 +1,15 @@
import { notFound } from 'next/navigation';
import featureFlagsConfig from '~/config/feature-flags.config';
function OrganizationAccountBillingLayout(props: React.PropsWithChildren) {
const isEnabled = featureFlagsConfig.enableOrganizationBilling;
if (!isEnabled) {
notFound();
}
return <>{props.children}</>;
}
export default OrganizationAccountBillingLayout;

View File

@@ -0,0 +1,19 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
function OrganizationAccountBillingPage() {
return (
<>
<PageHeader
title={<Trans i18nKey={'common:billingTabLabel'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />}
/>
<PageBody></PageBody>
</>
);
}
export default withI18n(OrganizationAccountBillingPage);

View File

@@ -0,0 +1,50 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
import { Page } from '@kit/ui/page';
import { AppSidebar } from './(components)/app-sidebar';
import { loadOrganizationWorkspace } from './(lib)/load-workspace';
interface Params {
account: string;
}
async function OrganizationWorkspaceLayout({
children,
params,
}: React.PropsWithChildren<{
params: Params;
}>) {
const data = await loadOrganizationWorkspace(params.account);
const ui = getUIStateCookies();
const sidebarCollapsed = ui.sidebarState === 'collapsed';
return (
<Page
sidebar={
<AppSidebar
collapsed={sidebarCollapsed}
account={params.account}
accounts={data.accounts.map(({ name, slug, picture_url }) => ({
label: name,
value: slug,
image: picture_url,
}))}
/>
}
>
{children}
</Page>
);
}
export default withI18n(OrganizationWorkspaceLayout);
function getUIStateCookies() {
return {
theme: parseThemeCookie(),
sidebarState: parseSidebarStateCookie(),
};
}

View File

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

View File

@@ -0,0 +1,144 @@
import { PlusCircledIcon } from '@radix-ui/react-icons';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import { withI18n } from '~/lib/i18n/with-i18n';
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 { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
interface Params {
params: {
account: string;
};
}
async function loadAccountMembers(account: string) {
const client = getSupabaseServerComponentClient();
const { data, error } = await client.rpc('get_account_members', {
account_slug: account,
});
if (error) {
console.error(error);
throw error;
}
return data ?? [];
}
async function loadInvitations(account: string) {
const client = getSupabaseServerComponentClient();
const { data, error } = await client.rpc('get_account_invitations', {
account_slug: account,
});
if (error) {
console.error(error);
throw error;
}
return data ?? [];
}
async function OrganizationAccountMembersPage({ params }: Params) {
const slug = params.account;
const [{ account, user }, members, invitations] = await Promise.all([
loadOrganizationWorkspace(slug),
loadAccountMembers(slug),
loadInvitations(slug),
]);
const canManageRoles = account.permissions.includes('roles.manage');
const isPrimaryOwner = account.primary_owner_user_id === user.id;
const permissions = {
canUpdateRole: canManageRoles,
canRemoveFromAccount: canManageRoles,
canTransferOwnership: isPrimaryOwner,
};
return (
<>
<PageHeader
title={<Trans i18nKey={'common:membersTabLabel'} />}
description={<Trans i18nKey={'common:membersTabDescription'} />}
/>
<PageBody>
<div
className={'mx-auto flex w-full max-w-3xl flex-col space-y-4 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>
Here you can manage the members of your organization.
</CardDescription>
</div>
<InviteMembersDialogContainer account={account.slug}>
<Button size={'sm'}>
<PlusCircledIcon className={'mr-2 w-4'} />
<span>Add Member</span>
</Button>
</InviteMembersDialogContainer>
</CardHeader>
<CardContent>
<AccountMembersTable
currentUserId={user.id}
permissions={permissions}
members={members}
/>
</CardContent>
</Card>
<Card>
<CardHeader className={'flex flex-row justify-between'}>
<div className={'flex flex-col space-y-1.5'}>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>
Here you can manage the pending invitations to your
organization.
</CardDescription>
</div>
</CardHeader>
<CardContent>
<AccountInvitationsTable
permissions={{
canUpdateInvitation: canManageRoles,
canRemoveInvitation: canManageRoles,
}}
invitations={invitations}
/>
</CardContent>
</Card>
</div>
</PageBody>
</>
);
}
export default withI18n(OrganizationAccountMembersPage);

View File

@@ -0,0 +1,64 @@
import loadDynamic from 'next/dynamic';
import { PlusIcon } from 'lucide-react';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page';
import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
const DashboardDemo = loadDynamic(
() => import('~/(dashboard)/home/[account]/(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 metadata = {
title: 'Organization Account Home',
};
function OrganizationAccountHomePage({
params,
}: {
params: {
account: string;
};
}) {
return (
<>
<AppHeader
title={<Trans i18nKey={'common:dashboardTabLabel'} />}
description={<Trans i18nKey={'common:dashboardTabDescription'} />}
account={params.account}
>
<Button>
<PlusIcon className={'mr-2 h-4'} />
<span>Add Widget</span>
</Button>
</AppHeader>
<PageBody>
<DashboardDemo />
</PageBody>
</>
);
}
export default withI18n(OrganizationAccountHomePage);

View File

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

View File

@@ -0,0 +1,87 @@
import { use } from 'react';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import featureFlagsConfig from '~/config/feature-flags.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
TeamAccountDangerZone,
UpdateOrganizationForm,
} from '@kit/team-accounts/components';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
export const metadata = {
title: 'Organization Settings',
};
const allowOrganizationDelete = featureFlagsConfig.enableOrganizationDeletion;
interface Params {
params: {
account: string;
};
}
function OrganizationSettingsPage({ params }: Params) {
const { account, user } = use(loadOrganizationWorkspace(params.account));
return (
<>
<PageHeader
title={<Trans i18nKey={'organization:settingsPageTitle'} />}
description={<Trans i18nKey={'organization:settingsPageDescription'} />}
/>
<PageBody>
<div className={'mx-auto flex max-w-5xl flex-col space-y-4'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:generalTabLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:generalTabLabelSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateOrganizationForm
accountId={account.id}
accountName={account.name}
/>
</CardContent>
</Card>
<If condition={allowOrganizationDelete}>
<Card className={'border-2 border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:dangerZoneSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<TeamAccountDangerZone userId={user.id} account={account} />
</CardContent>
</Card>
</If>
</div>
</PageBody>
</>
);
}
export default withI18n(OrganizationSettingsPage);

View File

@@ -0,0 +1,32 @@
'use client';
import { ArrowUpRightIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
export function BillingPortalRedirectButton({
children,
customerId,
className,
}: React.PropsWithChildren<{
customerId: string;
className?: string;
}>) {
return (
<form action={createBillingPortalSessionAction}>
<input type={'hidden'} name={'customerId'} value={customerId} />
<Button
data-test={'manage-billing-redirect-button'}
variant={'outline'}
className={className}
>
<span className={'flex items-center space-x-2'}>
<span>{children}</span>
<ArrowUpRightIcon className={'h-3'} />
</span>
</Button>
</form>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useEffect } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { ChevronRightIcon } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
export function CheckoutRedirectButton({
children,
onCheckoutCreated,
...props
}): React.PropsWithChildren<{
disabled?: boolean;
stripePriceId?: string;
recommended?: boolean;
organizationUid: string;
onCheckoutCreated?: (clientSecret: string) => void;
}> {
const [state, formAction] = useFormState(createCheckoutAction, {
clientSecret: '',
});
useEffect(() => {
if (state.clientSecret && onCheckoutCreated) {
onCheckoutCreated(state.clientSecret);
}
}, [state.clientSecret, onCheckoutCreated]);
return (
<form data-test={'checkout-form'} action={formAction}>
<CheckoutFormData
organizationUid={props.organizationUid}
priceId={props.stripePriceId}
/>
<SubmitCheckoutButton
disabled={props.disabled}
recommended={props.recommended}
>
{children}
</SubmitCheckoutButton>
</form>
);
}
function SubmitCheckoutButton(
props: React.PropsWithChildren<{
recommended?: boolean;
disabled?: boolean;
}>,
) {
const { pending } = useFormStatus();
return (
<Button
className={cn({
'bg-primary text-primary-foreground dark:bg-white dark:text-gray-900':
props.recommended,
})}
variant={props.recommended ? 'custom' : 'outline'}
disabled={props.disabled ?? pending}
>
<span className={'flex items-center space-x-2'}>
<span>{props.children}</span>
<ChevronRightIcon className={'h-4'} />
</span>
</Button>
);
}
function CheckoutFormData(
props: React.PropsWithChildren<{
organizationUid: string | undefined;
priceId: string | undefined;
}>,
) {
return (
<>
<input
type="hidden"
name={'organizationUid'}
defaultValue={props.organizationUid}
/>
<input type="hidden" name={'returnUrl'} defaultValue={getReturnUrl()} />
<input type="hidden" name={'priceId'} defaultValue={props.priceId} />
</>
);
}
function getReturnUrl() {
return isBrowser()
? [window.location.origin, window.location.pathname].join('')
: undefined;
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog';
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { XIcon } from 'lucide-react';
import pricingConfig, {
StripeCheckoutDisplayMode,
} from '@/config/pricing.config';
import { cn } from '@/lib/utils';
import If from '@/components/app/If';
import LogoImage from '@/components/app/Logo/LogoImage';
import Trans from '@/components/app/Trans';
const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_PUBLISHABLE_KEY) {
throw new Error(
'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
);
}
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
export default function EmbeddedStripeCheckout({
clientSecret,
onClose,
}: React.PropsWithChildren<{
clientSecret: string;
onClose?: () => void;
}>) {
return (
<EmbeddedCheckoutPopup key={clientSecret} onClose={onClose}>
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret }}
>
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
</EmbeddedCheckoutProvider>
</EmbeddedCheckoutPopup>
);
}
function EmbeddedCheckoutPopup({
onClose,
children,
}: React.PropsWithChildren<{
onClose?: () => void;
}>) {
const [open, setOpen] = useState(true);
const displayMode = pricingConfig.displayMode;
const isPopup = displayMode === StripeCheckoutDisplayMode.Popup;
const isOverlay = displayMode === StripeCheckoutDisplayMode.Overlay;
const className = cn({
[`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
isPopup,
[`bg-background !flex flex-col flex-1 fixed top-0 !max-h-full !max-w-full left-0 w-screen h-screen border-transparent shadow-transparent py-4 px-8`]:
isOverlay,
});
const close = () => {
setOpen(false);
if (onClose) {
onClose();
}
};
return (
<Dialog
defaultOpen
open={open}
onOpenChange={(open) => {
if (!open && onClose) {
onClose();
}
setOpen(open);
}}
>
<DialogContent
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<If condition={isOverlay}>
<div className={'mb-8'}>
<div className={'flex items-center justify-between'}>
<LogoImage />
<Button onClick={close} variant={'outline'}>
<Trans i18nKey={'common:cancel'} />
</Button>
</div>
</div>
</If>
<If condition={isPopup}>
<DialogPrimitiveClose asChild>
<Button
size={'icon'}
className={'absolute right-4 top-2 flex items-center'}
aria-label={'Close Checkout'}
onClick={close}
>
<XIcon className={'h-6 text-gray-900'} />
<span className="sr-only">
<Trans i18nKey={'common:cancel'} />
</span>
</Button>
</DialogPrimitiveClose>
</If>
<div
className={cn({
[`flex-1 rounded-xl bg-white p-8`]: isOverlay,
})}
>
{children}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import type Organization from '@/lib/organizations/types/organization';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import CheckoutRedirectButton from './checkout-redirect-button';
const EmbeddedStripeCheckout = dynamic(
() => import('./embedded-stripe-checkout'),
{
ssr: false,
},
);
const PlanSelectionForm: React.FC<{
organization: WithId<Organization>;
customerId: Maybe<string>;
}> = ({ organization, customerId }) => {
const [clientSecret, setClientSecret] = useState<string>();
const [retry, setRetry] = useState(0);
return (
<div className={'flex flex-col space-y-6'}>
<If condition={clientSecret}>
<EmbeddedStripeCheckout clientSecret={clientSecret!} />
</If>
<div className={'flex w-full flex-col justify-center space-y-8'}>
<PricingTable
CheckoutButton={(props) => {
return (
<ErrorBoundary
key={retry}
fallback={
<CheckoutErrorMessage
onRetry={() => setRetry((retry) => retry + 1)}
/>
}
>
<CheckoutRedirectButton
organizationUid={organization.uuid}
stripePriceId={props.stripePriceId}
recommended={props.recommended}
onCheckoutCreated={setClientSecret}
>
<Trans
i18nKey={'subscription:checkout'}
defaults={'Checkout'}
/>
</CheckoutRedirectButton>
</ErrorBoundary>
);
}}
/>
<If condition={customerId}>
<div className={'flex flex-col space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</If>
</div>
</div>
);
};
export default PlanSelectionForm;
function NoPermissionsAlert() {
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:noPermissionsAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:noPermissionsAlertBody'} />
</AlertDescription>
</Alert>
);
}
function CheckoutErrorMessage({ onRetry }: { onRetry: () => void }) {
return (
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-medium text-red-500'}>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</span>
<Button onClick={onRetry} variant={'ghost'}>
<Trans i18nKey={'common:retry'} />
</Button>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import React from 'react';
import type { ReadonlyURLSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import Trans from '@/components/app/Trans';
enum SubscriptionStatusQueryParams {
Success = 'success',
Cancel = 'cancel',
Error = 'error',
}
function PlansStatusAlertContainer() {
const status = useSubscriptionStatus();
if (status === undefined) {
return null;
}
return <PlansStatusAlert status={status as SubscriptionStatusQueryParams} />;
}
export default PlansStatusAlertContainer;
function PlansStatusAlert({
status,
}: {
status: SubscriptionStatusQueryParams;
}) {
switch (status) {
case SubscriptionStatusQueryParams.Cancel:
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCanceledAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCanceledAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Error:
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:unknownErrorAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Success:
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCompletedAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCompletedAlert'} />
</AlertDescription>
</Alert>
);
}
}
function useSubscriptionStatus() {
const params = useSearchParams();
return getStatus(params);
}
function getStatus(params: ReadonlyURLSearchParams | null) {
if (!params) {
return;
}
const error = params.has(SubscriptionStatusQueryParams.Error);
const canceled = params.has(SubscriptionStatusQueryParams.Cancel);
const success = params.has(SubscriptionStatusQueryParams.Success);
if (canceled) {
return SubscriptionStatusQueryParams.Cancel;
} else if (success) {
return SubscriptionStatusQueryParams.Success;
} else if (error) {
return SubscriptionStatusQueryParams.Error;
}
}

View File

@@ -0,0 +1,57 @@
'use client';
import useCurrentOrganization from '@/lib/organizations/hooks/use-current-organization';
import If from '@/components/app/If';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import PlanSelectionForm from './plan-selection-form';
import SubscriptionCard from './subscription-card';
const PlansContainer: React.FC = () => {
const organization = useCurrentOrganization();
if (!organization) {
return null;
}
const customerId = organization.subscription?.customerId;
const subscription = organization.subscription?.data;
if (!subscription) {
return (
<PlanSelectionForm customerId={customerId} organization={organization} />
);
}
return (
<div className={'flex flex-col space-y-4'}>
<div>
<div
className={'w-full divide-y rounded-xl border lg:w-9/12 xl:w-6/12'}
>
<div className={'p-6'}>
<SubscriptionCard subscription={subscription} />
</div>
<If condition={customerId}>
<div className={'flex justify-end p-6'}>
<div className={'flex flex-col items-end space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</div>
</If>
</div>
</div>
</div>
);
};
export default PlansContainer;

View File

@@ -0,0 +1,141 @@
import React, { useMemo } from 'react';
import Heading from '@/components/ui/heading';
import { CheckCircleIcon, XCircleIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import SubscriptionStatusBadge from '~/(dashboard)/home/[account]/(components)/organizations/SubscriptionStatusBadge';
import pricingConfig from '@/config/pricing.config';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import SubscriptionStatusAlert from './subscription-status-alert';
const SubscriptionCard: React.FC<{
subscription: OrganizationSubscription;
}> = ({ subscription }) => {
const details = useSubscriptionDetails(subscription.priceId);
const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd;
const isActive = subscription.status === 'active';
const language = getI18n().language;
const dates = useMemo(() => {
const endDate = new Date(subscription.periodEndsAt);
const trialEndDate =
subscription.trialEndsAt && new Date(subscription.trialEndsAt);
return {
endDate: endDate.toLocaleDateString(language),
trialEndDate: trialEndDate
? trialEndDate.toLocaleDateString(language)
: null,
};
}, [language, subscription]);
if (!details) {
return null;
}
return (
<div
className={'flex space-x-2'}
data-test={'subscription-card'}
data-test-status={subscription.status}
>
<div className={'flex w-9/12 flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-4'}>
<Heading level={4}>
<span data-test={'subscription-name'}>
{details.product.name}
</span>
</Heading>
<div>
<SubscriptionStatusBadge subscription={subscription} />
</div>
</div>
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
{details.product.description}
</span>
</div>
<If condition={isActive}>
<RenewStatusDescription
dates={dates}
cancelAtPeriodEnd={cancelAtPeriodEnd}
/>
</If>
<SubscriptionStatusAlert subscription={subscription} values={dates} />
</div>
<div className={'w-3/12'}>
<span className={'flex items-center justify-end space-x-1'}>
<PricingTable.Price>{details.plan.price}</PricingTable.Price>
<span className={'lowercase text-gray-500 dark:text-gray-400'}>
/{details.plan.name}
</span>
</span>
</div>
</div>
);
};
function RenewStatusDescription(
props: React.PropsWithChildren<{
cancelAtPeriodEnd: boolean;
dates: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
return (
<span className={'flex items-center space-x-1.5 text-sm'}>
<If condition={props.cancelAtPeriodEnd}>
<XCircleIcon className={'h-5 text-yellow-700'} />
<span>
<Trans
i18nKey={'subscription:cancelAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
<If condition={!props.cancelAtPeriodEnd}>
<CheckCircleIcon className={'h-5 text-green-700'} />
<span>
<Trans
i18nKey={'subscription:renewAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
</span>
);
}
function useSubscriptionDetails(priceId: string) {
const products = pricingConfig.products;
return useMemo(() => {
for (const product of products) {
for (const plan of product.plans) {
if (plan.stripePriceId === priceId) {
return { plan, product };
}
}
}
}, [products, priceId]);
}
export default SubscriptionCard;

View File

@@ -0,0 +1,68 @@
import classNames from 'clsx';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import Trans from '@/components/app/Trans';
function SubscriptionStatusAlert(
props: React.PropsWithChildren<{
subscription: OrganizationSubscription;
values: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
const status = props.subscription.status;
let message = '';
let type: 'success' | 'error' | 'warn';
switch (status) {
case 'active':
message = 'subscription:status.active.description';
type = 'success';
break;
case 'trialing':
message = 'subscription:status.trialing.description';
type = 'success';
break;
case 'canceled':
message = 'subscription:status.canceled.description';
type = 'warn';
break;
case 'incomplete':
message = 'subscription:status.incomplete.description';
type = 'warn';
break;
case 'incomplete_expired':
message = 'subscription:status.incomplete_expired.description';
type = 'error';
break;
case 'unpaid':
message = 'subscription:status.unpaid.description';
type = 'error';
break;
case 'past_due':
message = 'subscription:status.past_due.description';
type = 'error';
break;
default:
return null;
}
return (
<span
className={classNames('text-sm', {
'text-orange-700 dark:text-gray-400': type === 'warn',
'text-red-700 dark:text-red-400': type === 'error',
'text-green-700 dark:text-green-400': type === 'success',
})}
>
<Trans i18nKey={message} values={props.values} />
</span>
);
}
export default SubscriptionStatusAlert;

View File

@@ -0,0 +1,34 @@
import Heading from '@/components/ui/heading';
import { withI18n } from '@packages/i18n/with-i18n';
import Trans from '@/components/app/Trans';
import PlansStatusAlertContainer from './components/plan-status-alert-container';
import PlansContainer from './components/plans-container';
export const metadata = {
title: 'Subscription',
};
const SubscriptionSettingsPage = () => {
return (
<div className={'flex w-full flex-col space-y-4'}>
<div className={'flex flex-col space-y-1 px-2'}>
<Heading level={4}>
<Trans i18nKey={'common:subscriptionSettingsTabLabel'} />
</Heading>
<span className={'text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:subscriptionTabSubheading'} />
</span>
</div>
<PlansStatusAlertContainer />
<PlansContainer />
</div>
);
};
export default withI18n(SubscriptionSettingsPage);

View File

@@ -0,0 +1,91 @@
'use client';
import Link from 'next/link';
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import type { Stripe } from 'stripe';
import pathsConfig from '~/config/paths.config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
/**
* Retrieves the session status for a Stripe checkout session.
* Since we should only arrive here for a successful checkout, we only check
* for the `paid` status.
*
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
* @param {string} customerEmail - The email address of the customer associated with the session.
*
* @returns {ReactElement} - The component to render based on the session status.
*/
export function BillingSessionStatus({
customerEmail,
}: React.PropsWithChildren<{
status: Stripe.Checkout.Session['status'];
customerEmail: string;
}>) {
return <SuccessSessionStatus customerEmail={customerEmail} />;
}
function SuccessSessionStatus({
customerEmail,
}: React.PropsWithChildren<{
customerEmail: string;
}>) {
return (
<section
data-test={'payment-return-success'}
className={
'mx-auto max-w-xl rounded-xl border p-16 fade-in xl:drop-shadow-sm' +
' dark:border-dark-800 border-gray-100' +
' bg-background ease-out animate-in slide-in-from-bottom-8' +
' duration-1000 zoom-in-50 dark:shadow-2xl dark:shadow-primary/40'
}
>
<div
className={
'flex flex-col items-center justify-center space-y-4 text-center'
}
>
<CheckIcon
className={
'w-16 rounded-full bg-green-500 p-1 text-white ring-8' +
' ring-green-500/30 dark:ring-green-500/50'
}
/>
<Heading level={3}>
<span className={'mr-4 font-semibold'}>
<Trans i18nKey={'subscription:checkoutSuccessTitle'} />
</span>
🎉
</Heading>
<div
className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}
>
<p>
<Trans
i18nKey={'subscription:checkoutSuccessDescription'}
values={{ customerEmail }}
/>
</p>
</div>
<Button data-test={'checkout-success-back-button'} variant={'outline'}>
<Link href={pathsConfig.app.home}>
<span className={'flex items-center space-x-2.5'}>
<span>
<Trans i18nKey={'subscription:checkoutSuccessBackButton'} />
</span>
<ChevronRightIcon className={'h-4'} />
</span>
</Link>
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,26 @@
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
const EmbeddedStripeCheckout = dynamic(
() => {
return import('../../components/embedded-stripe-checkout');
},
{
ssr: false,
},
);
function RecoverCheckout({ clientSecret }: { clientSecret: string }) {
const router = useRouter();
return (
<EmbeddedStripeCheckout
clientSecret={clientSecret}
onClose={() => {
return router.replace('/settings/subscription');
}}
/>
);
}
export default RecoverCheckout;

View File

@@ -0,0 +1,81 @@
import { notFound, redirect } from 'next/navigation';
import { withI18n } from '@packages/i18n/with-i18n';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import createStripeClient from '@kit/stripe/get-stripe';
import requireSession from '@/lib/user/require-session';
import { BillingSessionStatus } from './components/billing-session-status';
import RecoverCheckout from './components/recover-checkout';
interface SessionPageProps {
searchParams: {
session_id: string;
};
}
async function ReturnStripeSessionPage({ searchParams }: SessionPageProps) {
const { status, customerEmail, clientSecret } = await loadStripeSession(
searchParams.session_id,
);
if (clientSecret) {
return <RecoverCheckout clientSecret={clientSecret} />;
}
return (
<>
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
<BillingSessionStatus
status={status}
customerEmail={customerEmail ?? ''}
/>
</div>
<div
className={
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
' !m-0 h-full'
}
/>
</>
);
}
export default withI18n(ReturnStripeSessionPage);
export async function loadStripeSession(sessionId: string) {
await requireSession(getSupabaseServerComponentClient());
// now we fetch the session from Stripe
// and check if it's still open
const stripe = await createStripeClient();
const session = await stripe.checkout.sessions
.retrieve(sessionId)
.catch(() => undefined);
if (!session) {
notFound();
}
const isSessionOpen = session.status === 'open';
const clientSecret = isSessionOpen ? session.client_secret : null;
const isEmbeddedMode = session.ui_mode === 'embedded';
// if the session is still open, we redirect the user to the checkout page
// in Stripe self hosted mode
if (isSessionOpen && !isEmbeddedMode && session.url) {
redirect(session.url);
}
// otherwise - we show the user the return page
// and display the details of the session
return {
status: session.status,
customerEmail: session.customer_details?.email,
clientSecret,
};
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useRouter } from 'next/navigation';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
const features = {
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,
};
export function HomeSidebarAccountSelector(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);
}
}}
/>
);
}

View File

@@ -0,0 +1,57 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
export function HomeSidebar() {
const collapsed = getSidebarCollapsed();
const accounts = use(loadUserAccounts());
return (
<Sidebar collapsed={collapsed}>
<SidebarContent className={'my-4'}>
<HomeSidebarAccountSelector collapsed={collapsed} accounts={accounts} />
</SidebarContent>
<SidebarContent className={`h-[calc(100%-160px)] overflow-y-auto`}>
<SidebarNavigation config={personalAccountSidebarConfig} />
</SidebarContent>
<div className={'absolute bottom-4 left-0 w-full'}>
<SidebarContent>
<ProfileDropdownContainer collapsed={collapsed} />
</SidebarContent>
</div>
</Sidebar>
);
}
function getSidebarCollapsed() {
return cookies().get('sidebar-collapsed')?.value === 'true';
}
async function loadUserAccounts() {
const client = getSupabaseServerComponentClient();
const { data: accounts, error } = await client
.from('user_accounts')
.select('*');
if (error) {
throw error;
}
return accounts.map(({ name, slug, picture_url }) => {
return {
label: name,
value: slug,
image: picture_url,
};
});
}

View File

@@ -0,0 +1,27 @@
'use client';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
export function ProfileDropdownContainer(props: { collapsed: boolean }) {
const userSession = useUserSession();
const signOut = useSignOut();
const session = userSession?.data ?? undefined;
return (
<div className={props.collapsed ? '' : 'w-full'}>
<PersonalAccountDropdown
paths={{
home: pathsConfig.app.home,
}}
className={'w-full'}
showProfileName={!props.collapsed}
session={session}
signOutRequested={() => signOut.mutateAsync()}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,93 @@
import { Heading } from '@kit/ui/heading';
export const metadata = {
title: 'About',
};
const AboutPage = () => {
return (
<div>
<div className={'container mx-auto'}>
<div className={'my-8 flex flex-col space-y-14'}>
<div className={'flex flex-col items-center space-y-4'}>
<Heading level={1}>About us</Heading>
<Heading level={2}>
We are a team of passionate developers and designers who love to
build great products.
</Heading>
</div>
<div
className={
'm-auto flex w-full max-w-xl flex-col items-center space-y-8' +
' justify-center text-gray-600 dark:text-gray-400'
}
>
<div>
We are a team of visionaries, dreamers, and doers who are on a
mission to change the world for the better
</div>
<div>
With a passion for innovation and a commitment to excellence, we
are dedicated to creating products and services that will improve
people&apos;s lives and make a positive impact on society.
</div>
<div>
It all started with a simple idea: to use technology to solve some
of the biggest challenges facing humanity. We realized that with
the right team and the right approach, we could make a difference
and leave a lasting legacy. And so, with a lot of hard work and
determination, we set out on a journey to turn our vision into
reality.
</div>
<div>
Today, we are proud to be a leader in our field, and our products
and services are used by millions of people all over the world.
But we&apos;re not done yet. We still have big dreams and even
bigger plans, and we&apos;re always looking for ways to push the
boundaries of what&apos;s possible.
</div>
<div>
Our Values: At the heart of everything we do is a set of core
values that guide us in all that we do. These values are what make
us who we are, and they are what set us apart from the rest.
</div>
<div>
<ul className={'flex list-decimal flex-col space-y-1 pl-4'}>
<li>
Innovation: We are always looking for new and better ways to
do things.
</li>
<li>
Excellence: We strive for excellence in all that we do, and we
never settle for less.
</li>
<li>
Responsibility: We take our responsibilities seriously, and we
always act with integrity.
</li>
<li>
Collaboration: We believe that by working together, we can
achieve more than we can on our own.
</li>
</ul>
</div>
<div>Yes, this was generated with ChatGPT</div>
</div>
</div>
</div>
</div>
);
};
export default AboutPage;

View File

@@ -0,0 +1,70 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { allPosts } from 'contentlayer/generated';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import Post from '../components/post';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata | undefined> {
const post = allPosts.find((post) => post.slug === params.slug);
if (!post) {
return;
}
const { title, date, description, image, slug } = post;
const url = [appConfig.url, 'blog', slug].join('/');
return {
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime: date,
url,
images: image
? [
{
url: image,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : [],
},
};
}
async function BlogPost({ params }: { params: { slug: string } }) {
const post = allPosts.find((post) => post.slug === params.slug);
if (!post) {
notFound();
}
return (
<div className={'container mx-auto'}>
<Script id={'ld-json'} type="application/ld+json">
{JSON.stringify(post.structuredData)}
</Script>
<Post post={post} content={post.body.code} />
</div>
);
}
export default withI18n(BlogPost);

View File

@@ -0,0 +1,33 @@
import Image from 'next/image';
import { cn } from '@kit/ui/utils';
type Props = {
title: string;
src: string;
preloadImage?: boolean;
className?: string;
};
export const CoverImage: React.FC<Props> = ({
title,
src,
preloadImage,
className,
}) => {
return (
<Image
className={cn(
'duration-250 block rounded-xl object-cover' +
' transition-all hover:opacity-90',
{
className,
},
)}
src={src}
priority={preloadImage}
alt={`Cover Image for ${title}`}
fill
/>
);
};

View File

@@ -0,0 +1,11 @@
import { format, parseISO } from 'date-fns';
type Props = {
dateString: string;
};
export const DateFormatter = ({ dateString }: Props) => {
const date = parseISO(dateString);
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
};

View File

@@ -0,0 +1,9 @@
import React from 'react';
export function DraftPostBadge({ children }: React.PropsWithChildren) {
return (
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
{children}
</span>
);
}

View File

@@ -0,0 +1,56 @@
import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
const PostHeader: React.FC<{
post: Post;
}> = ({ post }) => {
const { title, date, readingTime, description, image } = post;
// NB: change this to display the post's image
const displayImage = true;
const preloadImage = true;
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-4'}>
<Heading level={1}>{title}</Heading>
<Heading level={3}>
<span className={'font-normal text-muted-foreground'}>
{description}
</span>
</Heading>
</div>
<div className="flex">
<div className="flex flex-row items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
<div>
<DateFormatter dateString={date} />
</div>
<span>·</span>
<span>{readingTime} minutes reading</span>
</div>
</div>
<If condition={displayImage && image}>
{(imageUrl) => (
<div className="relative mx-auto h-[378px] w-full justify-center">
<CoverImage
preloadImage={preloadImage}
className="rounded-md"
title={title}
src={imageUrl}
/>
</div>
)}
</If>
</div>
);
};
export default PostHeader;

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { If } from '@kit/ui/if';
type Props = {
post: Post;
preloadImage?: boolean;
imageHeight?: string | number;
};
const DEFAULT_IMAGE_HEIGHT = 250;
function PostPreview({
post,
preloadImage,
imageHeight,
}: React.PropsWithChildren<Props>) {
const { title, image, date, readingTime, description } = post;
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
return (
<div className="rounded-xl transition-shadow duration-500 dark:text-gray-800">
<If condition={image}>
{(imageUrl) => (
<div className="relative mb-2 w-full" style={{ height }}>
<Link href={post.url}>
<CoverImage
preloadImage={preloadImage}
title={title}
src={imageUrl}
/>
</Link>
</div>
)}
</If>
<div className={'px-1'}>
<div className="flex flex-col space-y-1 px-1 py-2">
<h3 className="px-1 text-2xl font-bold leading-snug dark:text-white">
<Link href={post.url} className="hover:underline">
{title}
</Link>
</h3>
</div>
<div className="mb-2 flex flex-row items-center space-x-2 px-1 text-sm">
<div className="text-gray-600 dark:text-gray-300">
<DateFormatter dateString={date} />
</div>
<span className="text-gray-600 dark:text-gray-300">·</span>
<span className="text-gray-600 dark:text-gray-300">
{readingTime} mins reading
</span>
</div>
<p className="mb-4 px-1 text-sm leading-relaxed dark:text-gray-300">
{description}
</p>
</div>
</div>
);
}
export default PostPreview;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import type { Post as PostType } from 'contentlayer/generated';
import { Mdx } from '@kit/ui/mdx';
import PostHeader from './post-header';
export const Post: React.FC<{
post: PostType;
content: string;
}> = ({ post, content }) => {
return (
<div className={'mx-auto my-8 max-w-2xl'}>
<PostHeader post={post} />
<article className={'mx-auto flex justify-center'}>
<Mdx code={content} />
</article>
</div>
);
};
export default Post;

View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import PostPreview from '~/(marketing)/blog/components/post-preview';
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { GridList } from '../components/grid-list';
export const metadata: Metadata = {
title: `Blog - ${appConfig.name}`,
description: `Tutorials, Guides and Updates from our team`,
};
async function BlogPage() {
const livePosts = allPosts.filter((post) => {
const isProduction = appConfig.production;
return isProduction ? post.live : true;
});
return (
<div className={'container mx-auto'}>
<div className={'my-8 flex flex-col space-y-16'}>
<SitePageHeader
title={'Blog'}
subtitle={'Tutorials, Guides and Updates from our team'}
/>
<GridList>
{livePosts.map((post, idx) => {
return <PostPreview key={idx} post={post} />;
})}
</GridList>
</div>
</div>
);
}
export default withI18n(BlogPage);

View File

@@ -0,0 +1,7 @@
export function GridList({ children }: React.PropsWithChildren) {
return (
<div className="mb-16 grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-8 md:gap-y-12 lg:grid-cols-3 lg:gap-x-12">
{children}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import Link from 'next/link';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
const YEAR = new Date().getFullYear();
export function SiteFooter() {
return (
<footer className={'py-8 lg:py-24'}>
<div className={'container mx-auto'}>
<div className={'flex flex-col space-y-8 lg:flex-row lg:space-y-0'}>
<div
className={
'flex w-full space-x-2 lg:w-4/12 xl:w-3/12' +
' xl:space-x-6 2xl:space-x-8'
}
>
<div className={'flex flex-col space-y-4'}>
<div>
<AppLogo className={'w-[85px] md:w-[115px]'} />
</div>
<div>
<p className={'text-sm text-gray-500 dark:text-gray-400'}>
Add a short tagline about your product
</p>
</div>
<div className={'flex text-xs text-gray-500 dark:text-gray-400'}>
<p>
© Copyright {YEAR} {appConfig.name}. All Rights Reserved.
</p>
</div>
</div>
</div>
<div
className={
'flex flex-col space-y-8 lg:space-x-6 lg:space-y-0' +
' xl:space-x-16 2xl:space-x-20' +
' w-full lg:flex-row lg:justify-end'
}
>
<div>
<div className={'flex flex-col space-y-4'}>
<FooterSectionHeading>About</FooterSectionHeading>
<FooterSectionList>
<FooterLink>
<Link href={'#'}>Who we are</Link>
</FooterLink>
<FooterLink>
<Link href={'/blog'}>Blog</Link>
</FooterLink>
<FooterLink>
<Link href={'/contact'}>Contact</Link>
</FooterLink>
</FooterSectionList>
</div>
</div>
<div>
<div className={'flex flex-col space-y-4'}>
<FooterSectionHeading>Product</FooterSectionHeading>
<FooterSectionList>
<FooterLink>
<Link href={'/docs'}>Documentation</Link>
</FooterLink>
<FooterLink>
<Link href={'#'}>Help Center</Link>
</FooterLink>
<FooterLink>
<Link href={'#'}>Changelog</Link>
</FooterLink>
</FooterSectionList>
</div>
</div>
<div>
<div className={'flex flex-col space-y-4'}>
<FooterSectionHeading>Legal</FooterSectionHeading>
<FooterSectionList>
<FooterLink>
<Link href={'#'}>Terms of Service</Link>
</FooterLink>
<FooterLink>
<Link href={'#'}>Privacy Policy</Link>
</FooterLink>
<FooterLink>
<Link href={'#'}>Cookie Policy</Link>
</FooterLink>
</FooterSectionList>
</div>
</div>
</div>
</div>
</div>
</footer>
);
}
function FooterSectionHeading(props: React.PropsWithChildren) {
return (
<p>
<span className={'font-semibold'}>{props.children}</span>
</p>
);
}
function FooterSectionList(props: React.PropsWithChildren) {
return (
<ul className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}>
{props.children}
</ul>
);
}
function FooterLink(props: React.PropsWithChildren) {
return (
<li
className={
'text-sm [&>a]:transition-colors [&>a]:hover:text-gray-800' +
' dark:[&>a]:hover:text-white'
}
>
{props.children}
</li>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
import { Button } from '@kit/ui/button';
export function SiteHeaderAccountSection() {
const signOut = useSignOut();
const userSession = useUserSession();
if (userSession.data) {
return (
<PersonalAccountDropdown
session={userSession.data}
paths={{
home: pathsConfig.app.home,
}}
signOutRequested={() => signOut.mutateAsync()}
/>
);
}
return <AuthButtons />;
}
function AuthButtons() {
return (
<div className={'hidden space-x-2 lg:flex'}>
<Button variant={'link'}>
<Link href={pathsConfig.auth.signIn}>Sign In</Link>
</Button>
<Link href={pathsConfig.auth.signUp}>
<Button className={'rounded-full'}>
Sign Up
<ChevronRightIcon className={'h-4'} />
</Button>
</Link>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { SiteHeaderAccountSection } from '~/(marketing)/components/site-header-account-section';
import { SiteNavigation } from '~/(marketing)/components/site-navigation';
import { AppLogo } from '~/components/app-logo';
export function SiteHeader() {
return (
<div className={'container mx-auto'}>
<div className="flex h-16 items-center justify-between">
<div className={'w-4/12'}>
<AppLogo />
</div>
<div className={'hidden w-4/12 justify-center lg:flex'}>
<SiteNavigation />
</div>
<div className={'flex flex-1 items-center justify-end space-x-4'}>
<div className={'flex items-center'}></div>
<SiteHeaderAccountSection />
<div className={'flex lg:hidden'}>
<SiteNavigation />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import Link from 'next/link';
import { MenuIcon } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
} from '@kit/ui/navigation-menu';
const links = {
SignIn: {
label: 'Sign In',
path: '/auth/sign-in',
},
Blog: {
label: 'Blog',
path: '/blog',
},
Docs: {
label: 'Documentation',
path: '/docs',
},
Pricing: {
label: 'Pricing',
path: '/pricing',
},
FAQ: {
label: 'FAQ',
path: '/faq',
},
};
export function SiteNavigation() {
const className = `hover:underline text-sm`;
return (
<>
<div className={'hidden items-center lg:flex'}>
<NavigationMenu>
<NavigationMenuList className={'space-x-2.5'}>
<NavigationMenuItem>
<Link className={className} href={links.Blog.path}>
{links.Blog.label}
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link className={className} href={links.Docs.path}>
{links.Docs.label}
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link className={className} href={links.Pricing.path}>
{links.Pricing.label}
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link className={className} href={links.FAQ.path}>
{links.FAQ.label}
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div className={'flex items-center lg:hidden'}>
<MobileDropdown />
</div>
</>
);
}
function MobileDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label={'Open Menu'}>
<MenuIcon className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.values(links).map((item) => {
const className = 'flex w-full h-full items-center';
return (
<DropdownMenuItem key={item.path}>
<Link className={className} href={item.path}>
{item.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,20 @@
import { Heading } from '@kit/ui/heading';
import { cn } from '@kit/ui/utils';
export function SitePageHeader(props: {
title: string;
subtitle: string;
className?: string;
}) {
return (
<div
className={cn('flex flex-col items-center space-y-2.5', props.className)}
>
<Heading level={1}>{props.title}</Heading>
<Heading level={2} className={'text-muted-foreground'}>
<span className={'font-normal'}>{props.subtitle}</span>
</Heading>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { allDocumentationPages } from 'contentlayer/generated';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
import { withI18n } from '~/lib/i18n/with-i18n';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
const getPageBySlug = cache((slug: string) => {
return allDocumentationPages.find((post) => post.resolvedPath === slug);
});
interface PageParams {
params: {
slug: string[];
};
}
export const generateMetadata = ({ params }: PageParams) => {
const page = getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
}
const { title, description } = page;
return {
title,
description,
};
};
function DocumentationPage({ params }: PageParams) {
const page = getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
}
const { nextPage, previousPage, children } =
getDocumentationPageTree(page.resolvedPath) ?? {};
const description = page?.description ?? '';
return (
<div className={'container mx-auto'}>
<div className={'relative flex grow flex-col space-y-4 px-8 py-8'}>
<SitePageHeader
title={page.title}
subtitle={description}
className={'items-start'}
/>
<Mdx code={page.body.code} />
<If condition={children}>
<DocsCards pages={children ?? []} />
</If>
<div
className={
'flex flex-col justify-between space-y-4 md:flex-row md:space-x-8' +
' md:space-y-0'
}
>
<div className={'w-full'}>
<If condition={previousPage}>
{(page) => (
<DocumentationPageLink
page={page}
before={<ChevronLeftIcon className={'w-4'} />}
/>
)}
</If>
</div>
<div className={'w-full'}>
<If condition={nextPage}>
{(page) => (
<DocumentationPageLink
page={page}
after={<ChevronRightIcon className={'w-4'} />}
/>
)}
</If>
</div>
</div>
</div>
</div>
);
}
export default withI18n(DocumentationPage);

View File

@@ -0,0 +1,45 @@
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
export const DocsCard: React.FC<
React.PropsWithChildren<{
label: string;
subtitle?: string | null;
link?: { url: string; label: string };
}>
> = ({ label, subtitle, children, link }) => {
return (
<div className="flex flex-col">
<div
className={`flex grow flex-col space-y-2.5 border bg-background p-6
${link ? 'rounded-t-2xl border-b-0' : 'rounded-2xl'}`}
>
<h3 className="mt-0 text-lg font-semibold dark:text-white">{label}</h3>
{subtitle && (
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>{subtitle}</p>
</div>
)}
{children && <div className="text-sm">{children}</div>}
</div>
{link && (
<div className="rounded-b-2xl border bg-muted p-6 py-4 dark:bg-background">
<span className={'flex items-center space-x-2'}>
<Link
className={'text-sm font-medium hover:underline'}
href={`/docs/${link.url}`}
>
{link.label}
</Link>
<ChevronRightIcon className={'h-4'} />
</span>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,23 @@
import type { DocumentationPage } from 'contentlayer/generated';
import { DocsCard } from './docs-card';
export function DocsCards({ pages }: { pages: DocumentationPage[] }) {
return (
<div className={'grid grid-cols-1 gap-8 lg:grid-cols-2'}>
{pages.map((item) => {
return (
<DocsCard
key={item.label}
label={item.label}
subtitle={item.description}
link={{
url: item.resolvedPath,
label: item.cardCTA ?? 'Read more',
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDownIcon, MenuIcon } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree';
const DocsNavLink: React.FC<{
label: string;
url: string;
level: number;
activePath: string;
collapsible: boolean;
collapsed: boolean;
toggleCollapsed: () => void;
}> = ({
label,
url,
level,
activePath,
collapsible,
collapsed,
toggleCollapsed,
}) => {
const isCurrent = url == activePath;
const isFirstLevel = level === 0;
return (
<div className={getNavLinkClassName(isCurrent, isFirstLevel)}>
<Link
className="flex h-full max-w-full grow items-center space-x-2"
href={`/docs/${url}`}
>
<span className="block max-w-full truncate">{label}</span>
</Link>
{collapsible && (
<button
aria-label="Toggle children"
onClick={toggleCollapsed}
className="mr-2 shrink-0 px-2 py-1"
>
<span
className={`block w-2.5 ${collapsed ? '-rotate-90 transform' : ''}`}
>
<ChevronDownIcon className="h-4 w-4" />
</span>
</button>
)}
</div>
);
};
const Node: React.FC<{
node: ProcessedDocumentationPage;
level: number;
activePath: string;
}> = ({ node, level, activePath }) => {
const [collapsed, setCollapsed] = useState<boolean>(node.collapsed ?? false);
const toggleCollapsed = () => setCollapsed(!collapsed);
useEffect(() => {
if (
activePath == node.resolvedPath ||
node.children.map((_) => _.resolvedPath).includes(activePath)
) {
setCollapsed(false);
}
}, [activePath, node.children, node.resolvedPath]);
return (
<>
<DocsNavLink
label={node.label}
url={node.resolvedPath}
level={level}
activePath={activePath}
collapsible={node.collapsible}
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
/>
{node.children.length > 0 && !collapsed && (
<Tree tree={node.children} level={level + 1} activePath={activePath} />
)}
</>
);
};
function Tree({
tree,
level,
activePath,
}: {
tree: ProcessedDocumentationPage[];
level: number;
activePath: string;
}) {
return (
<div className={cn('w-full space-y-2.5 pl-3', level > 0 ? 'border-l' : '')}>
{tree.map((treeNode, index) => (
<Node
key={index}
node={treeNode}
level={level}
activePath={activePath}
/>
))}
</div>
);
}
export default function DocsNavigation({
tree,
}: {
tree: ProcessedDocumentationPage[];
}) {
const activePath = usePathname().replace('/docs/', '');
return (
<>
<aside
style={{
height: `calc(100vh - 64px)`,
}}
className="sticky top-2 hidden w-80 shrink-0 border-r p-4 lg:flex"
>
<Tree tree={tree} level={0} activePath={activePath} />
</aside>
<div className={'lg:hidden'}>
<FloatingDocumentationNavigation tree={tree} activePath={activePath} />
</div>
</>
);
}
function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) {
return cn(
'group flex h-8 items-center justify-between space-x-2 whitespace-nowrap rounded-md px-3 text-sm leading-none transition-colors',
{
[`bg-muted`]: isCurrent,
[`hover:bg-muted`]: !isCurrent,
[`font-semibold`]: isFirstLevel,
[`font-normal`]: !isFirstLevel && isCurrent,
[`hover:text-foreground-muted`]: !isFirstLevel && !isCurrent,
},
);
}
function FloatingDocumentationNavigation({
tree,
activePath,
}: React.PropsWithChildren<{
tree: ProcessedDocumentationPage[];
activePath: string;
}>) {
const body = useMemo(() => {
return isBrowser() ? document.body : null;
}, []);
const [isVisible, setIsVisible] = useState(false);
const enableScrolling = (element: HTMLElement) =>
(element.style.overflowY = '');
const disableScrolling = (element: HTMLElement) =>
(element.style.overflowY = 'hidden');
// enable/disable body scrolling when the docs are toggled
useEffect(() => {
if (!body) {
return;
}
if (isVisible) {
disableScrolling(body);
} else {
enableScrolling(body);
}
}, [isVisible, body]);
// hide docs when navigating to another page
useEffect(() => {
setIsVisible(false);
}, [activePath]);
const onClick = () => {
setIsVisible(!isVisible);
};
return (
<>
<If condition={isVisible}>
<div
className={
'fixed left-0 top-0 z-10 h-screen w-full p-4' +
' flex flex-col space-y-4 overflow-auto bg-white dark:bg-background'
}
>
<Heading level={1}>Table of Contents</Heading>
<Tree tree={tree} level={0} activePath={activePath} />
</div>
</If>
<Button
className={'fixed bottom-5 right-5 z-10 h-16 w-16 rounded-full'}
onClick={onClick}
>
<MenuIcon className={'h-8'} />
</Button>
</>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import type { DocumentationPage } from 'contentlayer/generated';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
export function DocumentationPageLink({
page,
before,
after,
}: React.PropsWithChildren<{
page: DocumentationPage;
before?: React.ReactNode;
after?: React.ReactNode;
}>) {
return (
<Link
className={cn(
`flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 ring-muted transition-all hover:ring-primary`,
{
'justify-start': before,
'justify-end self-end': after,
},
)}
href={`/docs/${page.resolvedPath}`}
>
<If condition={before}>{(node) => <>{node}</>}</If>
<span className={'flex flex-col space-y-1.5'}>
<span
className={
'text-xs font-semibold uppercase dark:text-gray-400' +
' text-gray-500'
}
>
{before ? `Previous` : ``}
{after ? `Next` : ``}
</span>
<span>{page.title}</span>
</span>
<If condition={after}>{(node) => <>{node}</>}</If>
</Link>
);
}

View File

@@ -0,0 +1,21 @@
import type { DocumentationPage } from 'contentlayer/generated';
import { allDocumentationPages } from 'contentlayer/generated';
import DocsNavigation from './components/docs-navigation';
import { buildDocumentationTree } from './utils/build-documentation-tree';
function DocsLayout({ children }: React.PropsWithChildren) {
const tree = buildDocumentationTree(allDocumentationPages);
return (
<div className={'container mx-auto'}>
<div className={'flex'}>
<DocsNavigation tree={tree} />
<div className={'flex w-full flex-col items-center'}>{children}</div>
</div>
</div>
);
}
export default DocsLayout;

View File

@@ -0,0 +1,30 @@
import { allDocumentationPages } from 'contentlayer/generated';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../components/site-page-header';
import { DocsCards } from './components/docs-cards';
import { buildDocumentationTree } from './utils/build-documentation-tree';
export const metadata = {
title: `Documentation - ${appConfig.name}`,
};
function DocsPage() {
const tree = buildDocumentationTree(allDocumentationPages);
return (
<div className={'my-8 flex flex-col space-y-16'}>
<SitePageHeader
title={'Documentation'}
subtitle={'Get started with our guides and tutorials'}
/>
<div>
<DocsCards pages={tree ?? []} />
</div>
</div>
);
}
export default withI18n(DocsPage);

View File

@@ -0,0 +1,53 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
export interface ProcessedDocumentationPage extends DocumentationPage {
collapsible: boolean;
pathSegments: string[];
nextPage: ProcessedDocumentationPage | DocumentationPage | null;
previousPage: ProcessedDocumentationPage | DocumentationPage | null;
children: DocsTree;
}
export type DocsTree = ProcessedDocumentationPage[];
/**
* Build a tree of documentation pages from a flat list of pages with path segments
* @param docs
* @param parentPathNames
*/
export const buildDocumentationTree = cache(
(docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => {
const level = parentPathNames.length;
const pages = docs
.filter(
(_) =>
_.pathSegments.length === level + 1 &&
_.pathSegments
.map(({ pathName }: { pathName: string }) => pathName)
.join('/')
.startsWith(parentPathNames.join('/')),
)
.sort(
(a, b) => a.pathSegments[level].order - b.pathSegments[level].order,
);
return pages.map((doc, index) => {
const children = buildDocumentationTree(
docs,
doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName),
);
return {
...doc,
pathSegments: doc.pathSegments || ([] as string[]),
collapsible: children.length > 0,
nextPage: children[0] || pages[index + 1],
previousPage: pages[index - 1],
children,
};
});
},
);

View File

@@ -0,0 +1,45 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
import { allDocumentationPages } from 'contentlayer/generated';
import { buildDocumentationTree } from './build-documentation-tree';
/**
* Retrieves a specific documentation page from the page tree by its path.
*
* @param {string} pagePath - The path of the documentation page to retrieve.
* @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any.
*/
export const getDocumentationPageTree = cache((pagePath: string) => {
const tree = buildDocumentationTree(allDocumentationPages);
type DocumentationPageWithChildren = DocumentationPage & {
previousPage?: DocumentationPage | null;
nextPage?: DocumentationPage | null;
children?: DocumentationPage[];
};
const findPageInTree = (
pages: DocumentationPageWithChildren[],
path: string,
): DocumentationPageWithChildren | undefined => {
for (const page of pages) {
if (page.resolvedPath === path) {
return page;
}
const hasChildren = page.children && page.children.length > 0;
if (hasChildren) {
const foundPage = findPageInTree(page.children ?? [], path);
if (foundPage) {
return foundPage;
}
}
}
};
return findPageInTree(tree, pagePath);
});

View File

@@ -0,0 +1,123 @@
import { ChevronDownIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../components/site-page-header';
export const metadata = {
title: 'FAQ',
};
const faqItems = [
{
question: `Do you offer a free trial?`,
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
},
{
question: `Can I cancel my subscription?`,
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
},
{
question: `Where can I find my invoices?`,
answer: `You can find your invoices in your account settings.`,
},
{
question: `What payment methods do you accept?`,
answer: `We accept all major credit cards and PayPal.`,
},
{
question: `Can I upgrade or downgrade my plan?`,
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
},
{
question: `Do you offer discounts for non-profits?`,
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
},
];
const FAQPage = () => {
const structuredData = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems.map((item) => {
return {
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
};
}),
};
return (
<div>
<script
key={'ld:json'}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className={'container mx-auto'}>
<div className={'my-8 flex flex-col space-y-16'}>
<SitePageHeader
title={'FAQ'}
subtitle={'Frequently Asked Questions'}
/>
<div
className={
'm-auto flex w-full max-w-xl items-center justify-center'
}
>
<div className="flex w-full flex-col">
{faqItems.map((item, index) => {
return <FaqItem key={index} item={item} />;
})}
</div>
</div>
</div>
</div>
</div>
);
};
export default withI18n(FAQPage);
function FaqItem({
item,
}: React.PropsWithChildren<{
item: {
question: string;
answer: string;
};
}>) {
return (
<details className={'group border-b px-2 py-4'}>
<summary
className={
'flex items-center justify-between hover:cursor-pointer hover:underline'
}
>
<h2
className={
'hover:underline-none cursor-pointer font-sans text-lg font-medium'
}
>
{item.question}
</h2>
<div>
<ChevronDownIcon
className={'h-5 transition duration-300 group-open:-rotate-180'}
/>
</div>
</summary>
<div
className={'flex flex-col space-y-2 py-1 text-muted-foreground'}
dangerouslySetInnerHTML={{ __html: item.answer }}
/>
</details>
);
}

View File

@@ -0,0 +1,18 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { SiteFooter } from './components/site-footer';
import { SiteHeader } from './components/site-header';
function SiteLayout(props: React.PropsWithChildren) {
return (
<>
<SiteHeader />
{props.children}
<SiteFooter />
</>
);
}
export default withI18n(SiteLayout);

View File

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

View File

@@ -0,0 +1,285 @@
import Image from 'next/image';
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
function Home() {
return (
<div className={'flex flex-col space-y-16'}>
<div className={'container mx-auto'}>
<div
className={
'my-12 flex flex-col items-center md:flex-row lg:my-16' +
' mx-auto flex-1 justify-center animate-in fade-in ' +
' duration-1000 slide-in-from-top-12'
}
>
<div className={'flex w-full flex-1 flex-col items-center space-y-8'}>
<Pill>
<span>The leading SaaS Starter Kit for ambitious developers</span>
</Pill>
<HeroTitle>
<span>The SaaS Solution for</span>
<span>developers and founders</span>
</HeroTitle>
<div>
<Heading
level={3}
className={'text-center font-medium text-muted-foreground'}
>
<span>Here you can write a short description of your SaaS</span>
</Heading>
<Heading
level={3}
className={'text-center font-medium text-muted-foreground'}
>
<span>
This subheading is usually laid out on multiple lines
</span>
</Heading>
<Heading
level={3}
className={'text-center font-medium text-muted-foreground'}
>
<span>Impress your customers, straight to the point.</span>
</Heading>
</div>
<div className={'flex flex-col items-center space-y-4'}>
<MainCallToActionButton />
<span className={'text-xs text-muted-foreground'}>
Free plan. No credit card required.
</span>
</div>
</div>
</div>
<div
className={
'mx-auto flex max-w-5xl justify-center py-12 animate-in fade-in ' +
' delay-300 duration-1000 slide-in-from-top-16 fill-mode-both'
}
>
<Image
priority
className={
'rounded-2xl' +
' shadow-primary/40 animate-in fade-in' +
' delay-300 duration-1000 ease-out zoom-in-50 fill-mode-both'
}
width={2688}
height={1824}
src={`/assets/images/dashboard-dark.webp`}
alt={`App Image`}
/>
</div>
</div>
<div className={'container mx-auto'}>
<div
className={
'flex flex-col items-center justify-center space-y-24 py-16'
}
>
<div
className={
'flex max-w-3xl flex-col items-center space-y-8 text-center'
}
>
<Pill>
<span>A modern, scalable, and secure SaaS Starter Kit</span>
</Pill>
<div className={'flex flex-col space-y-2.5'}>
<Heading level={2}>The best tool in the space</Heading>
<Heading level={3} className={'text-muted-foreground'}>
Unbeatable Features and Benefits for Your SaaS Business
</Heading>
</div>
</div>
</div>
</div>
<div className={'container mx-auto'}>
<div className={'flex flex-col space-y-4'}>
<FeatureShowcaseContainer>
<LeftFeatureContainer>
<div className={'flex flex-col space-y-4'}>
<Heading level={2}>Authentication</Heading>
<Heading level={3} className={'text-muted-foreground'}>
Secure and Easy-to-Use Authentication for Your SaaS Website
and API
</Heading>
</div>
<div>
Our authentication system is built on top of the
industry-leading PaaS such as Supabase and Firebase. It is
secure, easy-to-use, and fully customizable. It supports
email/password, social logins, and more.
</div>
<div>
<Button variant={'outline'}>
<span className={'flex items-center space-x-2'}>
<span>Get Started</span>
<ChevronRightIcon className={'h-3'} />
</span>
</Button>
</div>
</LeftFeatureContainer>
<RightFeatureContainer>
<Image
className="rounded-2xl"
src={'/assets/images/sign-in.webp'}
width={'626'}
height={'683'}
alt={'Sign In'}
/>
</RightFeatureContainer>
</FeatureShowcaseContainer>
<FeatureShowcaseContainer>
<LeftFeatureContainer>
<Image
className="rounded-2xl"
src={'/assets/images/dashboard.webp'}
width={'887'}
height={'743'}
alt={'Dashboard'}
/>
</LeftFeatureContainer>
<RightFeatureContainer>
<div className={'flex flex-col space-y-4'}>
<Heading level={1}>Dashboard</Heading>
<Heading level={2} className={'text-muted-foreground'}>
A fantastic dashboard to manage your SaaS business
</Heading>
<div>
Our dashboard offers an overview of your SaaS business. It
shows at a glance all you need to know about your business. It
is fully customizable and extendable.
</div>
</div>
</RightFeatureContainer>
</FeatureShowcaseContainer>
</div>
</div>
<div className={'container mx-auto'}>
<div
className={
'flex flex-col items-center justify-center space-y-16 py-16'
}
>
<div className={'flex flex-col items-center space-y-8 text-center'}>
<Pill>
Get started for free. No credit card required. Cancel anytime.
</Pill>
<div className={'flex flex-col space-y-1'}>
<Heading level={2}>
Ready to take your SaaS business to the next level?
</Heading>
<Heading level={3} className={'text-muted-foreground'}>
Get started on our free plan and upgrade when you are ready.
</Heading>
</div>
</div>
<div className={'w-full'}></div>
</div>
</div>
</div>
);
}
export default withI18n(Home);
function HeroTitle({ children }: React.PropsWithChildren) {
return (
<h1
className={
'text-center text-4xl md:text-5xl' +
' font-heading flex flex-col font-bold xl:text-7xl'
}
>
{children}
</h1>
);
}
function Pill(props: React.PropsWithChildren) {
return (
<h2
className={
'inline-flex w-auto items-center space-x-2' +
' rounded-full bg-gradient-to-br dark:from-gray-200 dark:via-gray-400' +
' bg-clip-text px-4 py-2 text-center text-sm dark:to-gray-700' +
' border font-normal text-muted-foreground shadow-sm dark:text-transparent'
}
>
{props.children}
</h2>
);
}
function FeatureShowcaseContainer(props: React.PropsWithChildren) {
return (
<div
className={
'flex flex-col items-center justify-between lg:flex-row' +
' lg:space-x-24'
}
>
{props.children}
</div>
);
}
function LeftFeatureContainer(props: React.PropsWithChildren) {
return (
<div className={'flex w-full flex-col space-y-8 lg:w-6/12'}>
{props.children}
</div>
);
}
function RightFeatureContainer(props: React.PropsWithChildren) {
return <div className={'flex w-full lg:w-6/12'}>{props.children}</div>;
}
function MainCallToActionButton() {
return (
<Button className={'rounded-full'}>
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-2'}>
<span>Get Started</span>
<ChevronRightIcon
className={
'h-4 animate-in fade-in slide-in-from-left-8' +
' delay-1000 duration-1000 zoom-in fill-mode-both'
}
/>
</span>
</Link>
</Button>
);
}

View File

@@ -0,0 +1,28 @@
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PricingTable } from '@kit/billing/components/pricing-table';
import { SitePageHeader } from '../components/site-page-header';
export const metadata = {
title: 'Pricing',
};
function PricingPage() {
return (
<div className={'container mx-auto'}>
<div className={'my-8 flex flex-col space-y-16'}>
<SitePageHeader
title={'Pricing'}
subtitle={'Our pricing is designed to scale with your business.'}
/>
</div>
<PricingTable paths={pathsConfig.auth} config={billingConfig} />
</div>
);
}
export default withI18n(PricingPage);

View File

@@ -0,0 +1,21 @@
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { Page } from '@/components/app/Page';
import AdminSidebar from '../../packages/admin/components/AdminSidebar';
import isUserSuperAdmin from './utils/is-user-super-admin';
async function AdminLayout({ children }: React.PropsWithChildren) {
const isAdmin = await isUserSuperAdmin();
if (!isAdmin) {
notFound();
}
const csrfToken = headers().get('X-CSRF-Token');
return <Page sidebar={<AdminSidebar />}>{children}</Page>;
}
export default AdminLayout;

View File

@@ -0,0 +1,21 @@
import { notFound } from 'next/navigation';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
export function withAdminSession<Args extends unknown[], Response>(
fn: (...params: Args) => Response,
) {
return async (...params: Args) => {
const isAdmin = await isUserSuperAdmin({
client: getSupabaseServerActionClient(),
});
if (!isAdmin) {
notFound();
}
return fn(...params);
};
}

View File

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

View File

@@ -0,0 +1,29 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const deleteOrganizationAction = withAdminSession(
async ({ id }: { id: number; csrfToken: string }) => {
const client = getClient();
Logger.info({ id }, `Admin requested to delete Organization`);
await deleteOrganization(client, {
organizationId: id,
});
revalidatePath('/admin/organizations', 'page');
Logger.info({ id }, `Organization account deleted`);
redirect('/admin/organizations');
},
);

View File

@@ -0,0 +1,95 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import type Organization from '@/lib/organizations/types/organization';
import { deleteOrganizationAction } from '../actions.server';
function DeleteOrganizationModal({
organization,
}: React.PropsWithChildren<{
organization: Organization;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await deleteOrganizationAction({
id: organization.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deleting Organization</DialogTitle>
</DialogHeader>
<form action={onConfirm}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to delete the organization{' '}
<b>{organization.name}</b>.
</p>
<p>
Delete this organization will potentially delete the data
associated with it.
</p>
<p>
<b>This action is not reversible</b>.
</p>
<p>Are you sure you want to do this?</p>
</div>
<div>
<Label>
Confirm by typing <b>DELETE</b>
<Input required type={'text'} pattern={'DELETE'} />
</Label>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button disabled={pending} variant={'destructive'}>
Yes, delete organization
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default DeleteOrganizationModal;

View File

@@ -0,0 +1,25 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';
interface Params {
params: {
uid: string;
};
}
async function DeleteOrganizationModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = await getOrganizationByUid(client, params.uid);
if (!data || error) {
throw new Error(`Organization not found`);
}
return <DeleteOrganizationModal organization={data} />;
}
export default AdminGuard(DeleteOrganizationModalPage);

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,139 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisVerticalIcon } from 'lucide-react';
import type UserData from '@kit/session/types/user-data';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import type Membership from '@/lib/organizations/types/membership';
import { DataTable } from '@/components/app/DataTable';
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
type Data = {
id: Membership['id'];
role: Membership['role'];
user: {
id: UserData['id'];
displayName: UserData['displayName'];
};
};
const columns: ColumnDef<Data>[] = [
{
header: 'Membership ID',
id: 'id',
accessorKey: 'id',
},
{
header: 'User ID',
id: 'user-id',
cell: ({ row }) => {
const userId = row.original.user.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${userId}`}>
{userId}
</Link>
);
},
},
{
header: 'Name',
id: 'name',
accessorKey: 'user.displayName',
},
{
header: 'Role',
cell: ({ row }) => {
return (
<div className={'inline-flex'}>
<RoleBadge role={row.original.role} />
</div>
);
},
},
{
header: 'Actions',
cell: ({ row }) => {
const membership = row.original;
const userId = membership.user.id;
return (
<div className={'flex'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}`}>View User</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsMembersTable({
memberships,
page,
perPage,
pageCount,
}: React.PropsWithChildren<{
memberships: Data[];
page: number;
perPage: number;
pageCount: number;
}>) {
const data = memberships.filter((membership) => {
return membership.user;
});
const router = useRouter();
const path = usePathname();
return (
<DataTable
tableProps={{
'data-test': 'admin-organization-members-table',
}}
onPaginationChange={({ pageIndex }) => {
const { pathname } = new URL(path, window.location.origin);
const page = pageIndex + 1;
router.push(pathname + '?page=' + page);
}}
pageCount={pageCount}
pageIndex={page - 1}
pageSize={perPage}
columns={columns}
data={data}
/>
);
}
export default OrganizationsMembersTable;

View File

@@ -0,0 +1,82 @@
import { use } from 'react';
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import AdminHeader from '@packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
import { getMembershipsByOrganizationUid } from '../../queries';
import OrganizationsMembersTable from './components/OrganizationsMembersTable';
interface AdminMembersPageParams {
params: {
uid: string;
};
searchParams: {
page?: string;
};
}
export const metadata = {
title: `Members | ${appConfig.name}`,
};
function AdminMembersPage(params: AdminMembersPageParams) {
const adminClient = getSupabaseServerComponentClient({ admin: true });
const uid = params.params.uid;
const perPage = 20;
const page = getPageFromQueryParams(params.searchParams.page);
const { data: memberships, count } = use(
getMembershipsByOrganizationUid(adminClient, { uid, page, perPage }),
);
const pageCount = count ? Math.ceil(count / perPage) : 0;
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage Members</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-4'}>
<Breadcrumbs />
<OrganizationsMembersTable
page={page}
perPage={perPage}
pageCount={pageCount}
memberships={memberships}
/>
</div>
</PageBody>
</div>
);
}
export default AdminMembersPage;
function Breadcrumbs() {
return (
<div className={'flex items-center space-x-2 p-2 text-xs'}>
<div className={'flex items-center space-x-1.5'}>
<Link href={'/admin'}>Admin</Link>
</div>
<ChevronRightIcon className={'w-3'} />
<Link href={'/admin/organizations'}>Organizations</Link>
<ChevronRightIcon className={'w-3'} />
<span>Members</span>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import pricingConfig from '@/config/pricing.config';
import { DataTable } from '@/components/app/DataTable';
import SubscriptionStatusBadge from '../../../(app)/[account]/components/organizations/SubscriptionStatusBadge';
import type { getOrganizations } from '../queries';
type Response = Awaited<ReturnType<typeof getOrganizations>>;
type Organizations = Response['organizations'];
const columns: ColumnDef<Organizations[0]>[] = [
{
header: 'ID',
accessorKey: 'id',
id: 'id',
size: 10,
},
{
header: 'UUID',
accessorKey: 'uuid',
id: 'uuid',
size: 200,
},
{
header: 'Name',
accessorKey: 'name',
id: 'name',
},
{
header: 'Subscription',
id: 'subscription',
cell: ({ row }) => {
const priceId = row.original?.subscription?.data?.priceId;
const plan = pricingConfig.products.find((product) => {
return product.plans.some((plan) => plan.stripePriceId === priceId);
});
if (plan) {
const price = plan.plans.find((plan) => plan.stripePriceId === priceId);
if (!price) {
return 'Unknown Price';
}
return `${plan.name} - ${price.name}`;
}
return '-';
},
},
{
header: 'Subscription Status',
id: 'subscription-status',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
if (!subscription) {
return '-';
}
return <SubscriptionStatusBadge subscription={subscription} />;
},
},
{
header: 'Subscription Period',
id: 'subscription-period',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
const i18n = getI18n();
const language = i18n.language ?? 'en';
if (!subscription) {
return '-';
}
const canceled = subscription.cancelAtPeriodEnd;
const date = subscription.periodEndsAt;
const formattedDate = new Date(date).toLocaleDateString(language);
return canceled ? (
<span className={'text-orange-500'}>Stops on {formattedDate}</span>
) : (
<span className={'text-green-500'}>Renews on {formattedDate}</span>
);
},
},
{
header: 'Members',
id: 'members',
cell: ({ row }) => {
const memberships = row.original.memberships.filter((item) => !item.code);
const invites = row.original.memberships.length - memberships.length;
const uid = row.original.uuid;
const length = memberships.length;
return (
<Link
data-test={'organization-members-link'}
href={`organizations/${uid}/members`}
className={'cursor-pointer hover:underline'}
>
{length} member{length === 1 ? '' : 's'}{' '}
{invites ? `(${invites} invites)` : ''}
</Link>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const organization = row.original;
const uid = organization.uuid;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(uid)}
>
Copy UUID
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/organizations/${uid}/members`}>
View Members
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={'text-red-500'}
href={`/admin/organizations/${uid}/delete`}
>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsTable({
organizations,
pageCount,
perPage,
page,
}: React.PropsWithChildren<{
organizations: Organizations;
pageCount: number;
perPage: number;
page: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-organizations-table',
}}
pageSize={perPage}
pageIndex={page - 1}
pageCount={pageCount}
columns={columns}
data={organizations}
/>
);
}
export default OrganizationsTable;

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,21 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page';
function OrganizationsAdminPageError() {
return (
<PageBody>
<Alert variant={'destructive'}>
<AlertTitle>Could not load organizations</AlertTitle>
<AlertDescription>
There was an error loading the organizations. Please check your
console errors.
</AlertDescription>
</Alert>
</PageBody>
);
}
export default OrganizationsAdminPageError;

View File

@@ -0,0 +1,14 @@
function OrganizationsLayout(
props: React.PropsWithChildren<{
modal: React.ReactNode;
}>,
) {
return (
<>
{props.children}
{props.modal}
</>
);
}
export default OrganizationsLayout;

View File

@@ -0,0 +1,69 @@
import AdminGuard from '@/packages/admin/components/AdminGuard';
import AdminHeader from '@/packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { Input } from '@kit/ui/input';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import OrganizationsTable from './components/OrganizationsTable';
import { getOrganizations } from './queries';
interface OrganizationsAdminPageProps {
searchParams: {
page?: string;
search?: string;
};
}
export const metadata = {
title: `Organizations | ${appConfig.name}`,
};
async function OrganizationsAdminPage({
searchParams,
}: OrganizationsAdminPageProps) {
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
const client = getSupabaseServerComponentClient({ admin: true });
const perPage = 10;
const search = searchParams.search || '';
const { organizations, count } = await getOrganizations(
client,
search,
page,
perPage,
);
const pageCount = count ? Math.ceil(count / perPage) : 0;
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage Organizations</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-4'}>
<form method={'GET'}>
<Input
name={'search'}
defaultValue={search}
placeholder={'Search Organization...'}
/>
</form>
<OrganizationsTable
perPage={perPage}
page={page}
pageCount={pageCount}
organizations={organizations}
/>
</div>
</PageBody>
</div>
);
}
export default AdminGuard(OrganizationsAdminPage);

View File

@@ -0,0 +1,132 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types';
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
import type MembershipRole from '@/lib/organizations/types/membership-role';
type Client = SupabaseClient<Database>;
export async function getOrganizations(
client: Client,
search: string,
page = 1,
perPage = 20,
) {
const startOffset = (page - 1) * perPage;
const endOffset = startOffset - 1 + perPage;
let query = client.from(ORGANIZATIONS_TABLE).select<
string,
UserOrganizationData['organization'] & {
memberships: {
userId: string;
role: MembershipRole;
code: string;
}[];
}
>(
`
id,
uuid,
name,
logoURL: logo_url,
memberships (
userId: user_id,
role,
code
),
subscription: organizations_subscriptions (
customerId: customer_id,
data: subscription_id (
id,
status,
currency,
interval,
cancelAtPeriodEnd: cancel_at_period_end,
intervalCount: interval_count,
priceId: price_id,
createdAt: created_at,
periodStartsAt: period_starts_at,
periodEndsAt: period_ends_at,
trialStartsAt: trial_starts_at,
trialEndsAt: trial_ends_at
)
)`,
{
count: 'exact',
},
);
if (search) {
query = query.ilike('name', `%${search}%`);
}
const {
data: organizations,
count,
error,
} = await query.range(startOffset, endOffset);
if (error) {
throw error;
}
return {
organizations,
count,
};
}
export async function getMembershipsByOrganizationUid(
client: Client,
params: {
uid: string;
page: number;
perPage: number;
},
) {
const startOffset = (params.page - 1) * params.perPage;
const endOffset = startOffset + params.perPage;
const { data, error, count } = await client
.from(MEMBERSHIPS_TABLE)
.select<
string,
{
id: number;
role: MembershipRole;
user: {
id: string;
displayName: string;
photoURL: string;
};
}
>(
`
id,
role,
user: user_id (
id,
displayName: display_name,
photoURL: photo_url
),
organization: organization_id !inner (
id,
uuid
)`,
{
count: 'exact',
},
)
.eq('organization.uuid', params.uid)
.is('code', null)
.range(startOffset, endOffset);
if (error) {
throw error;
}
return { data, count };
}

View File

@@ -0,0 +1,68 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
import AdminGuard from '../../packages/admin/components/AdminGuard';
import AdminHeader from '../../packages/admin/components/AdminHeader';
export const metadata = {
title: `Admin | ${appConfig.name}`,
};
async function AdminPage() {
const data = await loadData();
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Admin</AdminHeader>
<PageBody>
<AdminDashboard data={data} />
</PageBody>
</div>
);
}
export default AdminGuard(AdminPage);
async function loadData() {
const client = getSupabaseServerComponentClient({ admin: true });
const { count: usersCount } = await client.from('users').select('*', {
count: 'exact',
head: true,
});
const { count: organizationsCount } = await client
.from('organizations')
.select('*', {
count: 'exact',
head: true,
});
const { count: activeSubscriptions } = await client
.from('subscriptions')
.select(`*`, {
count: 'exact',
head: true,
})
.eq('status', 'active');
const { count: trialSubscriptions } = await client
.from('subscriptions')
.select(`*`, {
count: 'exact',
head: true,
})
.eq('status', 'trialing');
return {
usersCount: usersCount || 0,
organizationsCount: organizationsCount || 0,
activeSubscriptions: activeSubscriptions || 0,
trialSubscriptions: trialSubscriptions || 0,
};
}

View File

@@ -0,0 +1,127 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const banUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `876600h`);
});
export const reactivateUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `none`);
});
export const impersonateUser = withAdminSession(async ({ userId }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
const client = getClient();
const {
data: { user },
error,
} = await client.auth.admin.getUserById(userId);
if (error || !user) {
throw new Error(`Error fetching user`);
}
const email = user.email;
if (!email) {
throw new Error(`User has no email. Cannot impersonate`);
}
const { error: linkError, data } = await getClient().auth.admin.generateLink({
type: 'magiclink',
email,
options: {
redirectTo: `/`,
},
});
if (linkError || !data) {
throw new Error(`Error generating magic link`);
}
const response = await fetch(data.properties?.action_link, {
method: 'GET',
redirect: 'manual',
});
const location = response.headers.get('Location');
if (!location) {
throw new Error(`Error generating magic link. Location header not found`);
}
const hash = new URL(location).hash.substring(1);
const query = new URLSearchParams(hash);
const accessToken = query.get('access_token');
const refreshToken = query.get('refresh_token');
if (!accessToken || !refreshToken) {
throw new Error(
`Error generating magic link. Tokens not found in URL hash.`,
);
}
return {
accessToken,
refreshToken,
};
});
export const deleteUserAction = withAdminSession(
async ({ userId }: { userId: string; csrfToken: string }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
Logger.info({ userId }, `Admin requested to delete user account`);
// we don't want to send an email to the user
const sendEmail = false;
await deleteUser({
client: getClient(),
userId,
sendEmail,
});
revalidatePath('/admin/users', 'page');
Logger.info({ userId }, `User account deleted`);
redirect('/admin/users');
},
);
async function setBanDuration(userId: string, banDuration: string) {
await assertUserIsNotCurrentSuperAdmin(userId);
await getClient().auth.admin.updateUserById(userId, {
ban_duration: banDuration,
});
revalidatePath('/admin/users');
}
async function assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
const { data: user } = await getSupabaseServerActionClient().auth.getUser();
const currentUserId = user.user?.id;
if (!currentUserId) {
throw new Error(`Error fetching user`);
}
if (currentUserId === targetUserId) {
throw new Error(
`You cannot perform a destructive action on your own account as a Super Admin`,
);
}
}

View File

@@ -0,0 +1,32 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import BanUserModal from '../components/BanUserModal';
interface Params {
params: {
uid: string;
};
}
function BanUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
const user = data.user;
const isBanned = 'banned_until' in user && user.banned_until !== 'none';
if (isBanned) {
throw new Error(`The user is already banned`);
}
return <BanUserModal user={user} />;
}
export default AdminGuard(BanUserModalPage);

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import { banUser } from '../actions.server';
function BanUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = async () => {
await banUser({
userId: user.id,
csrfToken,
});
onDismiss();
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
<ErrorBoundary fallback={<BanErrorAlert />}>
<form action={onConfirm}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to ban <b>{displayText}</b>.
</p>
<p>
You can unban them later, but they will not be able to log
in or use their account until you do.
</p>
<Label>
Type <b>BAN</b> to confirm
<Input type="text" required pattern={'BAN'} />
</Label>
<p>Are you sure you want to do this?</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<SubmitButton />
</div>
</div>
</form>
</ErrorBoundary>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button disabled={pending} variant={'destructive'}>
Yes, ban user
</Button>
);
}
export default BanUserModal;
function BanErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>There was an error banning this user.</AlertTitle>
<AlertDescription>Check the logs for more information.</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { deleteUserAction } from '../actions.server';
function DeleteUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await deleteUserAction({
userId: user.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deleting User</DialogTitle>
</DialogHeader>
<form action={onConfirm}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to delete the user <b>{displayText}</b>.
</p>
<p>
Delete this user will also delete the organizations they are a
Owner of, and potentially the data associated with those
organizations.
</p>
<p>
<b>This action is not reversible</b>.
</p>
<p>Are you sure you want to do this?</p>
</div>
<div>
<Label>
Confirm by typing <b>DELETE</b>
<Input required type={'text'} pattern={'DELETE'} />
</Label>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button disabled={pending} variant={'destructive'}>
Yes, delete user
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default DeleteUserModal;

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useSupabase from '@kit/hooks/use-supabase';
import Spinner from '@/components/app/Spinner';
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{
tokens: {
accessToken: string;
refreshToken: string;
};
}>) {
const supabase = useSupabase();
const router = useRouter();
useEffect(() => {
async function setAuth() {
await supabase.auth.setSession({
refresh_token: tokens.refreshToken,
access_token: tokens.accessToken,
});
router.push('/dashboard');
}
void setAuth();
}, [router, tokens, supabase.auth]);
return (
<div
className={
'flex h-screen w-screen flex-1 flex-col items-center justify-center'
}
>
<div className={'flex flex-col items-center space-y-4'}>
<Spinner />
<div>
<p>Setting up your session...</p>
</div>
</div>
</div>
);
}
export default ImpersonateUserAuthSetter;

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import { impersonateUser } from '../actions.server';
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';
function ImpersonateUserConfirmationModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const [error, setError] = useState<boolean>();
const [tokens, setTokens] = useState<{
accessToken: string;
refreshToken: string;
}>();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
try {
const response = await impersonateUser({
userId: user.id,
csrfToken,
});
setTokens(response);
} catch (e) {
setError(true);
}
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Impersonate User</DialogTitle>
</DialogHeader>
<If condition={tokens}>
{(tokens) => {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
}}
</If>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Impersonation Error</AlertTitle>
<AlertDescription>
Sorry, something went wrong. Please check the logs.
</AlertDescription>
</Alert>
</If>
<If condition={!error && !tokens}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to impersonate the account belonging to{' '}
<b>{displayText}</b> with ID <b>{user.id}</b>.
</p>
<p>
You will be able to log in as them, see and do everything they
can. To return to your own account, simply log out.
</p>
<p>
Like Uncle Ben said, with great power comes great
responsibility. Use this power wisely.
</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button
type={'button'}
disabled={pending}
variant={'destructive'}
onClick={onConfirm}
>
Yes, let&apos;s do it
</Button>
</div>
</div>
</If>
</DialogContent>
</Dialog>
);
}
export default ImpersonateUserConfirmationModal;

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { reactivateUser } from '../actions.server';
function ReactivateUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await reactivateUser({
userId: user.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reactivate User</DialogTitle>
</DialogHeader>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to reactivate the account belonging to{' '}
<b>{displayText}</b>.
</p>
<p>Are you sure you want to do this?</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button disabled={pending} onClick={onConfirm}>
Yes, reactivate user
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default ReactivateUserModal;

View File

@@ -0,0 +1,25 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteUserModal from '../components/DeleteUserModal';
interface Params {
params: {
uid: string;
};
}
function DeleteUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
return <DeleteUserModal user={data.user} />;
}
export default AdminGuard(DeleteUserModalPage);

View File

@@ -0,0 +1,25 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import ImpersonateUserConfirmationModal from '../components/ImpersonateUserConfirmationModal';
interface Params {
params: {
uid: string;
};
}
function ImpersonateUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
return <ImpersonateUserConfirmationModal user={data.user} />;
}
export default AdminGuard(ImpersonateUserModalPage);

View File

@@ -0,0 +1,34 @@
import { use } from 'react';
import { redirect } from 'next/navigation';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import ReactivateUserModal from '../components/ReactivateUserModal';
interface Params {
params: {
uid: string;
};
}
function ReactivateUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
const user = data.user;
const isActive = !('banned_until' in user) || user.banned_until === 'none';
if (isActive) {
redirect(`/admin/users`);
}
return <ReactivateUserModal user={user} />;
}
export default AdminGuard(ReactivateUserModalPage);

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import { EllipsisVerticalIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import If from '@/components/app/If';
function UserActionsDropdown({
uid,
isBanned,
}: React.PropsWithChildren<{
uid: string;
isBanned: boolean;
}>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'}>
<span className={'flex items-center space-x-2.5'}>
<span>Manage User</span>
<EllipsisVerticalIcon className={'w-4'} />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${uid}/impersonate`}>Impersonate</Link>
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link
className={'text-orange-500'}
href={`/admin/users/${uid}/ban`}
>
Ban
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${uid}/reactivate`}>Reactivate</Link>
</DropdownMenuItem>
</If>
<DropdownMenuItem asChild>
<Link className={'text-red-500'} href={`/admin/users/${uid}/delete`}>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default UserActionsDropdown;

View File

@@ -0,0 +1,241 @@
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { PageBody } from '@/components/app/Page';
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
import AdminGuard from '../../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../../packages/admin/components/AdminHeader';
import UserActionsDropdown from './components/UserActionsDropdown';
interface Params {
params: {
uid: string;
};
}
export const metadata = {
title: `Manage User | ${configuration.name}`,
};
async function AdminUserPage({ params }: Params) {
const uid = params.uid;
const data = await loadData(uid);
const { auth, user } = data;
const displayName = user?.displayName;
const authUser = auth?.user;
const email = authUser?.email;
const phone = authUser?.phone;
const organizations = data.organizations ?? [];
const isBanned = Boolean(
authUser && 'banned_until' in authUser && authUser.banned_until !== 'none',
);
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage User</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-6'}>
<div className={'flex justify-between'}>
<Breadcrumbs displayName={displayName ?? email ?? ''} />
<div>
<UserActionsDropdown uid={uid} isBanned={isBanned} />
</div>
</div>
<Card>
<CardHeader>
<CardTitle>User Details</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex items-center space-x-2'}>
<div>
<Label>Status</Label>
</div>
<div className={'inline-flex'}>
{isBanned ? (
<Badge variant={'destructive'}>Banned</Badge>
) : (
<Badge variant={'success'}>Active</Badge>
)}
</div>
</div>
<Label>
Display name
<Input
className={'max-w-sm'}
defaultValue={displayName ?? ''}
disabled
/>
</Label>
<Label>
Email
<Input
className={'max-w-sm'}
defaultValue={email ?? ''}
disabled
/>
</Label>
<Label>
Phone number
<Input
className={'max-w-sm'}
defaultValue={phone ?? ''}
disabled
/>
</Label>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organizations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization ID</TableHead>
<TableHead>UUID</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((membership) => {
const organization = membership.organization;
const href = `/admin/organizations/${organization.uuid}/members`;
return (
<TableRow key={membership.id}>
<TableCell>{organization.id}</TableCell>
<TableCell>{organization.uuid}</TableCell>
<TableCell>
<Link className={'hover:underline'} href={href}>
{organization.name}
</Link>
</TableCell>
<TableCell>
<div className={'inline-flex'}>
<RoleBadge role={membership.role} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</PageBody>
</div>
);
}
export default AdminGuard(AdminUserPage);
async function loadData(uid: string) {
const client = getSupabaseServerComponentClient({ admin: true });
const authUser = client.auth.admin.getUserById(uid);
const userData = client
.from('users')
.select(
`
id,
displayName: display_name,
photoURL: photo_url,
onboarded
`,
)
.eq('id', uid)
.single();
const organizationsQuery = client
.from('memberships')
.select<
string,
{
id: number;
role: MembershipRole;
organization: {
id: number;
uuid: string;
name: string;
};
}
>(
`
id,
role,
organization: organization_id !inner (
id,
uuid,
name
)
`,
)
.eq('user_id', uid);
const [auth, user, organizations] = await Promise.all([
authUser,
userData,
organizationsQuery,
]);
return {
auth: auth.data,
user: user.data,
organizations: organizations.data,
};
}
function Breadcrumbs(
props: React.PropsWithChildren<{
displayName: string;
}>,
) {
return (
<div className={'flex items-center space-x-1 p-2 text-xs'}>
<Link href={'/admin'}>Admin</Link>
<ChevronRightIcon className={'w-3'} />
<Link href={'/admin/users'}>Users</Link>
<ChevronRightIcon className={'w-3'} />
<span>{props.displayName}</span>
</div>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import type UserData from '@kit/session/types/user-data';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kit/ui/tooltip';
import { DataTable } from '@/components/app/DataTable';
import If from '@/components/app/If';
type UserRow = {
id: string;
email: string | undefined;
phone: string | undefined;
createdAt: string;
updatedAt: string | undefined;
lastSignInAt: string | undefined;
banDuration: string | undefined;
data: UserData;
};
const columns: ColumnDef<UserRow>[] = [
{
header: '',
id: 'avatar',
size: 10,
cell: ({ row }) => {
const user = row.original;
const data = user.data;
const displayName = data?.displayName;
const photoUrl = data?.photoUrl;
const displayText = displayName ?? user.email ?? user.phone ?? '';
return (
<Tooltip>
<TooltipTrigger>
<Avatar>
{photoUrl ? <AvatarImage src={photoUrl} /> : null}
<AvatarFallback>{displayText[0]}</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{displayText}</TooltipContent>
</Tooltip>
);
},
},
{
header: 'ID',
id: 'id',
size: 30,
cell: ({ row }) => {
const id = row.original.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${id}`}>
{id}
</Link>
);
},
},
{
header: 'Email',
id: 'email',
cell: ({ row }) => {
const email = row.original.email;
return (
<span title={email} className={'block max-w-full truncate'}>
{email}
</span>
);
},
},
{
header: 'Name',
size: 50,
id: 'displayName',
cell: ({ row }) => {
return row.original.data?.displayName ?? '';
},
},
{
header: 'Created at',
id: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
const i18n = getI18n();
const language = i18n.language ?? 'en';
const createdAtLabel = date.toLocaleDateString(language);
return <span>{createdAtLabel}</span>;
},
},
{
header: 'Last sign in',
id: 'lastSignInAt',
cell: ({ row }) => {
const lastSignInAt = row.original.lastSignInAt;
if (!lastSignInAt) {
return <span>-</span>;
}
const date = new Date(lastSignInAt);
return <span suppressHydrationWarning>{date.toLocaleString()}</span>;
},
},
{
header: 'Status',
id: 'status',
cell: ({ row }) => {
const banDuration = row.original.banDuration;
if (!banDuration || banDuration === 'none') {
return (
<Badge className={'inline-flex'} color={'success'}>
Active
</Badge>
);
}
return (
<Badge className={'inline-flex'} color={'error'}>
Banned
</Badge>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const user = row.original;
const banDuration = row.original.banDuration;
const isBanned = banDuration && banDuration !== 'none';
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(user.id)}
>
Copy user ID
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/5'
}
href={`/admin/users/${user.id}/ban`}
>
Ban User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5'
}
href={`/admin/users/${user.id}/delete`}
>
Delete User
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/reactivate`}>
Reactivate User
</Link>
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function UsersTable({
users,
page,
pageCount,
perPage,
}: React.PropsWithChildren<{
users: UserRow[];
pageCount: number;
page: number;
perPage: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-users-table',
}}
pageIndex={page - 1}
pageSize={perPage}
pageCount={pageCount}
data={users}
columns={columns}
/>
);
}
export default UsersTable;

Some files were not shown because too many files have changed in this diff Show More