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

View File

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

View File

@@ -0,0 +1,61 @@
'use client';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
import { Trans } from '@kit/ui/trans';
export function AppSidebarNavigation({
account,
}: React.PropsWithChildren<{
account: string;
}>) {
return (
<>
{getOrganizationAccountSidebarConfig(account).routes.map(
(item, index) => {
if ('divider' in item) {
return <SidebarDivider key={index} />;
}
if ('children' in item) {
return (
<SidebarGroup
key={item.label}
label={<Trans i18nKey={item.label} defaults={item.label} />}
collapsible={item.collapsible}
collapsed={item.collapsed}
>
{item.children.map((child) => {
return (
<SidebarItem
key={child.path}
end={child.end}
path={child.path}
Icon={child.Icon}
>
<Trans i18nKey={child.label} defaults={child.label} />
</SidebarItem>
);
})}
</SidebarGroup>
);
}
return (
<SidebarItem
key={item.path}
end={item.end}
path={item.path}
Icon={item.Icon}
>
<Trans i18nKey={item.label} defaults={item.label} />
</SidebarItem>
);
},
)}
</>
);
}
export default AppSidebarNavigation;

View File

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

View File

@@ -0,0 +1,349 @@
'use client';
import { useMemo } from 'react';
import { ArrowDownIcon, ArrowUpIcon, MenuIcon } from 'lucide-react';
import { Line, LineChart, ResponsiveContainer, XAxis } from 'recharts';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
export default function DashboardDemo() {
const mrr = useMemo(() => generateDemoData(), []);
const visitors = useMemo(() => generateDemoData(), []);
const returningVisitors = useMemo(() => generateDemoData(), []);
const churn = useMemo(() => generateDemoData(), []);
const netRevenue = useMemo(() => generateDemoData(), []);
const fees = useMemo(() => generateDemoData(), []);
const newCustomers = useMemo(() => generateDemoData(), []);
const tickets = useMemo(() => generateDemoData(), []);
const activeUsers = useMemo(() => generateDemoData(), []);
return (
<div className={'flex flex-col space-y-6 pb-36'}>
<div
className={
'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3' +
' 2xl:grid-cols-4'
}
>
<Card>
<CardHeader>
<CardTitle>Monthly Recurring Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${mrr[1]}`}</Figure>
<Trend trend={'up'}>20%</Trend>
</div>
<Chart data={mrr[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${netRevenue[1]}`}</Figure>
<Trend trend={'up'}>12%</Trend>
</div>
<Chart data={netRevenue[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fees</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`$${fees[1]}`}</Figure>
<Trend trend={'up'}>9%</Trend>
</div>
<Chart data={fees[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>New Customers</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{`${newCustomers[1]}`}</Figure>
<Trend trend={'down'}>-25%</Trend>
</div>
<Chart data={newCustomers[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Visitors</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{visitors[1]}</Figure>
<Trend trend={'down'}>-4.3%</Trend>
</div>
<Chart data={visitors[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Returning Visitors</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{returningVisitors[1]}</Figure>
<Trend trend={'stale'}>10%</Trend>
</div>
<Chart data={returningVisitors[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Churn</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{churn[1]}%</Figure>
<Trend trend={'up'}>-10%</Trend>
</div>
<Chart data={churn[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Support Tickets</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{tickets[1]}</Figure>
<Trend trend={'up'}>-30%</Trend>
</div>
<Chart data={tickets[0]} />
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<CardTitle>Active Users</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{activeUsers[1]}</Figure>
<Trend trend={'up'}>10%</Trend>
</div>
<Chart data={activeUsers[0]} />
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<CardTitle>Customers</CardTitle>
</CardHeader>
<CardContent>
<CustomersTable />
</CardContent>
</Card>
</div>
</div>
);
}
function generateDemoData() {
const today = new Date();
const formatter = new Intl.DateTimeFormat('en-us', {
month: 'long',
year: '2-digit',
});
const data: { value: string; name: string }[] = [];
for (let n = 8; n > 0; n -= 1) {
const date = new Date(today.getFullYear(), today.getMonth() - n, 1);
data.push({
name: formatter.format(date),
value: (Math.random() * 10).toFixed(1),
});
}
return [data, data[data.length - 1].value] as [typeof data, string];
}
function Chart(
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
) {
return (
<div className={'h-36'}>
<ResponsiveContainer width={'100%'} height={'100%'}>
<LineChart width={400} height={100} data={props.data}>
<Line
className={'text-primary'}
type="monotone"
dataKey="value"
stroke="currentColor"
strokeWidth={2.5}
dot={false}
/>
<XAxis
style={{ fontSize: 9 }}
axisLine={false}
tickSize={0}
dataKey="name"
height={15}
dy={10}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
function CustomersTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
<TableHead>MRR</TableHead>
<TableHead>Logins</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Pippin Oddo</TableCell>
<TableCell>Pro</TableCell>
<TableCell>$100.2</TableCell>
<TableCell>920</TableCell>
<TableCell>
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Väinö Pánfilo</TableCell>
<TableCell>Basic</TableCell>
<TableCell>$40.6</TableCell>
<TableCell>300</TableCell>
<TableCell>
<BadgeWithTrend trend={'stale'}>Possible Churn</BadgeWithTrend>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Giorgos Quinten</TableCell>
<TableCell>Pro</TableCell>
<TableCell>$2004.3</TableCell>
<TableCell>1000</TableCell>
<TableCell>
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Adhelm Otis</TableCell>
<TableCell>Basic</TableCell>
<TableCell>$0</TableCell>
<TableCell>10</TableCell>
<TableCell>
<BadgeWithTrend trend={'down'}>Churned</BadgeWithTrend>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
function BadgeWithTrend(props: React.PropsWithChildren<{ trend: string }>) {
const className = useMemo(() => {
switch (props.trend) {
case 'up':
return 'text-green-500';
case 'down':
return 'text-destructive';
case 'stale':
return 'text-orange-500';
}
}, [props.trend]);
return (
<Badge variant={'outline'}>
<span className={className}>{props.children}</span>
</Badge>
);
}
function Figure(props: React.PropsWithChildren) {
return <div className={'text-4xl font-bold'}>{props.children}</div>;
}
function Trend(
props: React.PropsWithChildren<{
trend: 'up' | 'down' | 'stale';
}>,
) {
const Icon = useMemo(() => {
switch (props.trend) {
case 'up':
return <ArrowUpIcon className={'h-4 text-green-500'} />;
case 'down':
return <ArrowDownIcon className={'h-4 text-destructive'} />;
case 'stale':
return <MenuIcon className={'h-4 text-orange-500'} />;
}
}, [props.trend]);
return (
<div>
<BadgeWithTrend trend={props.trend}>
<span className={'flex items-center space-x-0.5'}>
{Icon}
<span>{props.children}</span>
</span>
</BadgeWithTrend>
</div>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Trans } from '@kit/ui/trans';
export const MobileAppNavigation = (
props: React.PropsWithChildren<{
slug: string;
}>,
) => {
const signOut = useSignOut();
const Links = getOrganizationAccountSidebarConfig(props.slug).routes.map(
(item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
return (
<DropdownLink
key={item.path}
Icon={item.Icon}
path={item.path}
label={item.label}
/>
);
},
);
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MenuIcon className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<OrganizationsModal />
{Links}
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOutIcon className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}
function OrganizationsModal() {
const router = useRouter();
return (
<Dialog>
<DialogTrigger>
<DropdownMenuItem
className={'h-12'}
onSelect={(e) => e.preventDefault()}
>
<button className={'flex items-center space-x-4'}>
<HomeIcon className={'h-6'} />
<span>
<Trans i18nKey={'common:yourOrganizations'} />
</span>
</button>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'common:yourOrganizations'} />
</DialogTitle>
</DialogHeader>
<div className={'flex flex-col space-y-6 py-4'}>
<AccountSelector
onAccountChange={(value) => {
const path = value
? pathsConfig.app.accountHome.replace('[account]', value)
: pathsConfig.app.home;
router.replace(path);
}}
accounts={[]}
features={{
enableOrganizationAccounts:
featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation:
featureFlagsConfig.enableOrganizationCreation,
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import loadDynamic from 'next/dynamic';
import { PlusIcon } from 'lucide-react';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page';
import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
const DashboardDemo = loadDynamic(
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
{
ssr: false,
loading: () => (
<div
className={
'flex h-full flex-1 flex-col items-center justify-center space-y-4' +
' py-24'
}
>
<Spinner />
<div>
<Trans i18nKey={'common:loading'} />
</div>
</div>
),
},
);
export const metadata = {
title: 'Organization Account Home',
};
function OrganizationAccountHomePage({
params,
}: {
params: {
account: string;
};
}) {
return (
<>
<AppHeader
title={<Trans i18nKey={'common:dashboardTabLabel'} />}
description={<Trans i18nKey={'common:dashboardTabDescription'} />}
account={params.account}
>
<Button>
<PlusIcon className={'mr-2 h-4'} />
<span>Add Widget</span>
</Button>
</AppHeader>
<PageBody>
<DashboardDemo />
</PageBody>
</>
);
}
export default withI18n(OrganizationAccountHomePage);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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