Cleanup
This commit is contained in:
@@ -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();
|
||||
}
|
||||
19
apps/web/app/(dashboard)/home/(user)/account/layout.tsx
Normal file
19
apps/web/app/(dashboard)/home/(user)/account/layout.tsx
Normal 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);
|
||||
3
apps/web/app/(dashboard)/home/(user)/account/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/(user)/account/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
26
apps/web/app/(dashboard)/home/(user)/account/page.tsx
Normal file
26
apps/web/app/(dashboard)/home/(user)/account/page.tsx
Normal 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);
|
||||
15
apps/web/app/(dashboard)/home/(user)/billing/layout.tsx
Normal file
15
apps/web/app/(dashboard)/home/(user)/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function UserBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default UserBillingLayout;
|
||||
19
apps/web/app/(dashboard)/home/(user)/billing/page.tsx
Normal file
19
apps/web/app/(dashboard)/home/(user)/billing/page.tsx
Normal 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);
|
||||
10
apps/web/app/(dashboard)/home/(user)/layout.tsx
Normal file
10
apps/web/app/(dashboard)/home/(user)/layout.tsx
Normal 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);
|
||||
3
apps/web/app/(dashboard)/home/(user)/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
24
apps/web/app/(dashboard)/home/(user)/page.tsx
Normal file
24
apps/web/app/(dashboard)/home/(user)/page.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
15
apps/web/app/(dashboard)/home/[account]/billing/layout.tsx
Normal file
15
apps/web/app/(dashboard)/home/[account]/billing/layout.tsx
Normal 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;
|
||||
19
apps/web/app/(dashboard)/home/[account]/billing/page.tsx
Normal file
19
apps/web/app/(dashboard)/home/[account]/billing/page.tsx
Normal 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);
|
||||
50
apps/web/app/(dashboard)/home/[account]/layout.tsx
Normal file
50
apps/web/app/(dashboard)/home/[account]/layout.tsx
Normal 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(),
|
||||
};
|
||||
}
|
||||
3
apps/web/app/(dashboard)/home/[account]/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/[account]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
144
apps/web/app/(dashboard)/home/[account]/members/page.tsx
Normal file
144
apps/web/app/(dashboard)/home/[account]/members/page.tsx
Normal 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);
|
||||
64
apps/web/app/(dashboard)/home/[account]/page.tsx
Normal file
64
apps/web/app/(dashboard)/home/[account]/page.tsx
Normal 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);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
87
apps/web/app/(dashboard)/home/[account]/settings/page.tsx
Normal file
87
apps/web/app/(dashboard)/home/[account]/settings/page.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/web/app/(dashboard)/home/components/home-sidebar.tsx
Normal file
57
apps/web/app/(dashboard)/home/components/home-sidebar.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
apps/web/app/(dashboard)/home/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
93
apps/web/app/(marketing)/about/page.tsx
Normal file
93
apps/web/app/(marketing)/about/page.tsx
Normal 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'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're not done yet. We still have big dreams and even
|
||||
bigger plans, and we're always looking for ways to push the
|
||||
boundaries of what'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;
|
||||
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal 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);
|
||||
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal file
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
};
|
||||
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal file
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal file
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal 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;
|
||||
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal file
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal 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;
|
||||
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal file
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal 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;
|
||||
41
apps/web/app/(marketing)/blog/page.tsx
Normal file
41
apps/web/app/(marketing)/blog/page.tsx
Normal 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);
|
||||
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal file
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal file
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
apps/web/app/(marketing)/components/site-header.tsx
Normal file
29
apps/web/app/(marketing)/components/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal file
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal file
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal 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);
|
||||
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal file
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal file
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal file
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/(marketing)/docs/layout.tsx
Normal file
21
apps/web/app/(marketing)/docs/layout.tsx
Normal 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;
|
||||
30
apps/web/app/(marketing)/docs/page.tsx
Normal file
30
apps/web/app/(marketing)/docs/page.tsx
Normal 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);
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
123
apps/web/app/(marketing)/faq/page.tsx
Normal file
123
apps/web/app/(marketing)/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/(marketing)/layout.tsx
Normal file
18
apps/web/app/(marketing)/layout.tsx
Normal 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);
|
||||
3
apps/web/app/(marketing)/loading.tsx
Normal file
3
apps/web/app/(marketing)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
285
apps/web/app/(marketing)/page.tsx
Normal file
285
apps/web/app/(marketing)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/(marketing)/pricing/page.tsx
Normal file
28
apps/web/app/(marketing)/pricing/page.tsx
Normal 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);
|
||||
21
apps/web/app/admin/layout.tsx
Normal file
21
apps/web/app/admin/layout.tsx
Normal 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;
|
||||
21
apps/web/app/admin/lib/actions-utils.ts
Normal file
21
apps/web/app/admin/lib/actions-utils.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
3
apps/web/app/admin/loading.tsx
Normal file
3
apps/web/app/admin/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
3
apps/web/app/admin/organizations/@modal/default.tsx
Normal file
3
apps/web/app/admin/organizations/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
82
apps/web/app/admin/organizations/[uid]/members/page.tsx
Normal file
82
apps/web/app/admin/organizations/[uid]/members/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
3
apps/web/app/admin/organizations/default.tsx
Normal file
3
apps/web/app/admin/organizations/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
21
apps/web/app/admin/organizations/error.tsx
Normal file
21
apps/web/app/admin/organizations/error.tsx
Normal 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;
|
||||
14
apps/web/app/admin/organizations/layout.tsx
Normal file
14
apps/web/app/admin/organizations/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
function OrganizationsLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
{props.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsLayout;
|
||||
69
apps/web/app/admin/organizations/page.tsx
Normal file
69
apps/web/app/admin/organizations/page.tsx
Normal 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);
|
||||
132
apps/web/app/admin/organizations/queries.ts
Normal file
132
apps/web/app/admin/organizations/queries.ts
Normal 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 };
|
||||
}
|
||||
68
apps/web/app/admin/page.tsx
Normal file
68
apps/web/app/admin/page.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
127
apps/web/app/admin/users/@modal/[uid]/actions.server.ts
Normal file
127
apps/web/app/admin/users/@modal/[uid]/actions.server.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
32
apps/web/app/admin/users/@modal/[uid]/ban/page.tsx
Normal file
32
apps/web/app/admin/users/@modal/[uid]/ban/page.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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's do it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserConfirmationModal;
|
||||
@@ -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;
|
||||
25
apps/web/app/admin/users/@modal/[uid]/delete/page.tsx
Normal file
25
apps/web/app/admin/users/@modal/[uid]/delete/page.tsx
Normal 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);
|
||||
25
apps/web/app/admin/users/@modal/[uid]/impersonate/page.tsx
Normal file
25
apps/web/app/admin/users/@modal/[uid]/impersonate/page.tsx
Normal 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);
|
||||
34
apps/web/app/admin/users/@modal/[uid]/reactivate/page.tsx
Normal file
34
apps/web/app/admin/users/@modal/[uid]/reactivate/page.tsx
Normal 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);
|
||||
3
apps/web/app/admin/users/@modal/default.tsx
Normal file
3
apps/web/app/admin/users/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
241
apps/web/app/admin/users/[uid]/page.tsx
Normal file
241
apps/web/app/admin/users/[uid]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
apps/web/app/admin/users/components/UsersTable.tsx
Normal file
239
apps/web/app/admin/users/components/UsersTable.tsx
Normal 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;
|
||||
3
apps/web/app/admin/users/default.tsx
Normal file
3
apps/web/app/admin/users/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
21
apps/web/app/admin/users/error.tsx
Normal file
21
apps/web/app/admin/users/error.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
function UsersAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Could not load users</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an error loading the users. Please check your console
|
||||
errors.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersAdminPageError;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user