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;
|
||||
Reference in New Issue
Block a user