Cleanup
This commit is contained in:
36
apps/web/.env.development
Normal file
36
apps/web/.env.development
Normal file
@@ -0,0 +1,36 @@
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_PRODUCT_NAME=Makerkit
|
||||
|
||||
# SUPABASE
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||
|
||||
NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true
|
||||
|
||||
EMAIL_SENDER=test@makerkit.dev
|
||||
EMAIL_PORT=54325
|
||||
EMAIL_HOST=localhost
|
||||
EMAIL_TLS=false
|
||||
EMAIL_USER=user
|
||||
EMAIL_PASSWORD=password
|
||||
|
||||
# STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
# FEATURE FLAGS
|
||||
NEXT_PUBLIC_ENABLE_ACCOUNT_DELETION=true
|
||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
|
||||
NEXT_PUBLIC_ENABLE_ORGANIZATION_DELETION=true
|
||||
NEXT_PUBLIC_ENABLE_ORGANIZATION_INVITATIONS=true
|
||||
NEXT_PUBLIC_ENABLE_ORGANIZATION_BILLING=true
|
||||
|
||||
# LOCALES
|
||||
NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales
|
||||
|
||||
# PATHS
|
||||
# Please make sure to update these in the app's paths configuration as well
|
||||
SIGN_IN_PATH=/auth/sign-in
|
||||
SIGN_UP_PATH=/auth/sign-up
|
||||
ORGANIZATION_ACCOUNTS_PATH=/home
|
||||
INVITATION_PAGE_PATH=/invite
|
||||
28
apps/web/README.md
Normal file
28
apps/web/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Create T3 App
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
@@ -0,0 +1,12 @@
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
/**
|
||||
* Refreshes the user session on the server when updating the user profile.
|
||||
*/
|
||||
export async function refreshSessionAction() {
|
||||
const supabase = getSupabaseServerActionClient();
|
||||
|
||||
await supabase.auth.refreshSession();
|
||||
}
|
||||
19
apps/web/app/(dashboard)/home/(user)/account/layout.tsx
Normal file
19
apps/web/app/(dashboard)/home/(user)/account/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function UserSettingsLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:yourAccountTabLabel'} />}
|
||||
description={'Manage your account settings'}
|
||||
/>
|
||||
|
||||
<PageBody>{props.children}</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(UserSettingsLayout);
|
||||
3
apps/web/app/(dashboard)/home/(user)/account/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/(user)/account/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
26
apps/web/app/(dashboard)/home/(user)/account/page.tsx
Normal file
26
apps/web/app/(dashboard)/home/(user)/account/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
|
||||
|
||||
function PersonalAccountSettingsPage() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'container mx-auto flex max-w-2xl flex-1 flex-col items-center'
|
||||
}
|
||||
>
|
||||
<PersonalAccountSettingsContainer
|
||||
features={{
|
||||
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
|
||||
}}
|
||||
paths={{
|
||||
callback: pathsConfig.auth.callback,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PersonalAccountSettingsPage);
|
||||
15
apps/web/app/(dashboard)/home/(user)/billing/layout.tsx
Normal file
15
apps/web/app/(dashboard)/home/(user)/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function UserBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default UserBillingLayout;
|
||||
19
apps/web/app/(dashboard)/home/(user)/billing/page.tsx
Normal file
19
apps/web/app/(dashboard)/home/(user)/billing/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function PersonalAccountBillingPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:billingTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PersonalAccountBillingPage);
|
||||
10
apps/web/app/(dashboard)/home/(user)/layout.tsx
Normal file
10
apps/web/app/(dashboard)/home/(user)/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||
return <Page sidebar={<HomeSidebar />}>{children}</Page>;
|
||||
}
|
||||
|
||||
export default withI18n(UserHomeLayout);
|
||||
3
apps/web/app/(dashboard)/home/(user)/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
24
apps/web/app/(dashboard)/home/(user)/page.tsx
Normal file
24
apps/web/app/(dashboard)/home/(user)/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function UserHomePage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:homeTabLabel'} defaults={'Home'} />}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey={'common:homeTabDescription'}
|
||||
defaults={'Welcome to your Home Page'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(UserHomePage);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
|
||||
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
export function AppHeader({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
account,
|
||||
}: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
account: string;
|
||||
}>) {
|
||||
return (
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
mobileNavigation={<MobileAppNavigation slug={account} />}
|
||||
>
|
||||
{children}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
|
||||
|
||||
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function AppSidebarNavigation({
|
||||
account,
|
||||
}: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
{getOrganizationAccountSidebarConfig(account).routes.map(
|
||||
(item, index) => {
|
||||
if ('divider' in item) {
|
||||
return <SidebarDivider key={index} />;
|
||||
}
|
||||
|
||||
if ('children' in item) {
|
||||
return (
|
||||
<SidebarGroup
|
||||
key={item.label}
|
||||
label={<Trans i18nKey={item.label} defaults={item.label} />}
|
||||
collapsible={item.collapsible}
|
||||
collapsed={item.collapsed}
|
||||
>
|
||||
{item.children.map((child) => {
|
||||
return (
|
||||
<SidebarItem
|
||||
key={child.path}
|
||||
end={child.end}
|
||||
path={child.path}
|
||||
Icon={child.Icon}
|
||||
>
|
||||
<Trans i18nKey={child.label} defaults={child.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
key={item.path}
|
||||
end={item.end}
|
||||
path={item.path}
|
||||
Icon={item.Icon}
|
||||
>
|
||||
<Trans i18nKey={item.label} defaults={item.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppSidebarNavigation;
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react';
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { AppSidebarNavigation } from './app-sidebar-navigation';
|
||||
|
||||
type AccountModel = {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
const features = {
|
||||
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
|
||||
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,
|
||||
};
|
||||
|
||||
export function AppSidebar(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar collapsed={props.collapsed}>
|
||||
{({ collapsed, setCollapsed }) => (
|
||||
<SidebarContainer
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
account={props.account}
|
||||
accounts={props.accounts}
|
||||
/>
|
||||
)}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContainer(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) {
|
||||
const { account, accounts } = props;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarContent className={'my-4'}>
|
||||
<AccountSelector
|
||||
selectedAccount={account}
|
||||
accounts={accounts}
|
||||
collapsed={props.collapsed}
|
||||
features={features}
|
||||
onAccountChange={(value) => {
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
/>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent className={`h-[calc(100%-160px)] overflow-y-auto`}>
|
||||
<AppSidebarNavigation account={account} />
|
||||
</SidebarContent>
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<ProfileDropdownContainer collapsed={props.collapsed} />
|
||||
|
||||
<AppSidebarFooterMenu
|
||||
collapsed={props.collapsed}
|
||||
setCollapsed={props.setCollapsed}
|
||||
/>
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSidebarFooterMenu(props: {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<CollapsibleButton
|
||||
collapsed={props.collapsed}
|
||||
onClick={props.setCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleButton({
|
||||
collapsed,
|
||||
onClick,
|
||||
}: React.PropsWithChildren<{
|
||||
collapsed: boolean;
|
||||
onClick: (collapsed: boolean) => void;
|
||||
}>) {
|
||||
const className = cn(
|
||||
`bg-background absolute -right-[10.5px] bottom-4 cursor-pointer block`,
|
||||
);
|
||||
|
||||
const iconClassName =
|
||||
'bg-background text-gray-300 dark:text-gray-600 h-5 w-5';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className={className}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => onClick(!collapsed)}
|
||||
>
|
||||
<ArrowRightCircleIcon
|
||||
className={cn(iconClassName, {
|
||||
hidden: !collapsed,
|
||||
})}
|
||||
/>
|
||||
|
||||
<ArrowLeftCircleIcon
|
||||
className={cn(iconClassName, {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent sideOffset={20}>
|
||||
<Trans
|
||||
i18nKey={
|
||||
collapsed ? 'common:expandSidebar' : 'common:collapseSidebar'
|
||||
}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, MenuIcon } from 'lucide-react';
|
||||
import { Line, LineChart, ResponsiveContainer, XAxis } from 'recharts';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
export default function DashboardDemo() {
|
||||
const mrr = useMemo(() => generateDemoData(), []);
|
||||
const visitors = useMemo(() => generateDemoData(), []);
|
||||
const returningVisitors = useMemo(() => generateDemoData(), []);
|
||||
const churn = useMemo(() => generateDemoData(), []);
|
||||
const netRevenue = useMemo(() => generateDemoData(), []);
|
||||
const fees = useMemo(() => generateDemoData(), []);
|
||||
const newCustomers = useMemo(() => generateDemoData(), []);
|
||||
const tickets = useMemo(() => generateDemoData(), []);
|
||||
const activeUsers = useMemo(() => generateDemoData(), []);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-6 pb-36'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3' +
|
||||
' 2xl:grid-cols-4'
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Recurring Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{`$${mrr[1]}`}</Figure>
|
||||
<Trend trend={'up'}>20%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={mrr[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{`$${netRevenue[1]}`}</Figure>
|
||||
<Trend trend={'up'}>12%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={netRevenue[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fees</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{`$${fees[1]}`}</Figure>
|
||||
<Trend trend={'up'}>9%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={fees[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Customers</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{`${newCustomers[1]}`}</Figure>
|
||||
<Trend trend={'down'}>-25%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={newCustomers[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Visitors</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{visitors[1]}</Figure>
|
||||
<Trend trend={'down'}>-4.3%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={visitors[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Returning Visitors</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{returningVisitors[1]}</Figure>
|
||||
<Trend trend={'stale'}>10%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={returningVisitors[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Churn</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{churn[1]}%</Figure>
|
||||
<Trend trend={'up'}>-10%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={churn[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Support Tickets</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{tickets[1]}</Figure>
|
||||
<Trend trend={'up'}>-30%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={tickets[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Users</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{activeUsers[1]}</Figure>
|
||||
<Trend trend={'up'}>10%</Trend>
|
||||
</div>
|
||||
|
||||
<Chart data={activeUsers[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customers</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CustomersTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generateDemoData() {
|
||||
const today = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-us', {
|
||||
month: 'long',
|
||||
year: '2-digit',
|
||||
});
|
||||
|
||||
const data: { value: string; name: string }[] = [];
|
||||
|
||||
for (let n = 8; n > 0; n -= 1) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() - n, 1);
|
||||
|
||||
data.push({
|
||||
name: formatter.format(date),
|
||||
value: (Math.random() * 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
return [data, data[data.length - 1].value] as [typeof data, string];
|
||||
}
|
||||
|
||||
function Chart(
|
||||
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
|
||||
) {
|
||||
return (
|
||||
<div className={'h-36'}>
|
||||
<ResponsiveContainer width={'100%'} height={'100%'}>
|
||||
<LineChart width={400} height={100} data={props.data}>
|
||||
<Line
|
||||
className={'text-primary'}
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
style={{ fontSize: 9 }}
|
||||
axisLine={false}
|
||||
tickSize={0}
|
||||
dataKey="name"
|
||||
height={15}
|
||||
dy={10}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>MRR</TableHead>
|
||||
<TableHead>Logins</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Pippin Oddo</TableCell>
|
||||
<TableCell>Pro</TableCell>
|
||||
<TableCell>$100.2</TableCell>
|
||||
<TableCell>920</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Väinö Pánfilo</TableCell>
|
||||
<TableCell>Basic</TableCell>
|
||||
<TableCell>$40.6</TableCell>
|
||||
<TableCell>300</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'stale'}>Possible Churn</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Giorgos Quinten</TableCell>
|
||||
<TableCell>Pro</TableCell>
|
||||
<TableCell>$2004.3</TableCell>
|
||||
<TableCell>1000</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'up'}>Healthy</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Adhelm Otis</TableCell>
|
||||
<TableCell>Basic</TableCell>
|
||||
<TableCell>$0</TableCell>
|
||||
<TableCell>10</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={'down'}>Churned</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeWithTrend(props: React.PropsWithChildren<{ trend: string }>) {
|
||||
const className = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return 'text-green-500';
|
||||
case 'down':
|
||||
return 'text-destructive';
|
||||
case 'stale':
|
||||
return 'text-orange-500';
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<Badge variant={'outline'}>
|
||||
<span className={className}>{props.children}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure(props: React.PropsWithChildren) {
|
||||
return <div className={'text-4xl font-bold'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
function Trend(
|
||||
props: React.PropsWithChildren<{
|
||||
trend: 'up' | 'down' | 'stale';
|
||||
}>,
|
||||
) {
|
||||
const Icon = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return <ArrowUpIcon className={'h-4 text-green-500'} />;
|
||||
case 'down':
|
||||
return <ArrowDownIcon className={'h-4 text-destructive'} />;
|
||||
case 'stale':
|
||||
return <MenuIcon className={'h-4 text-orange-500'} />;
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BadgeWithTrend trend={props.trend}>
|
||||
<span className={'flex items-center space-x-0.5'}>
|
||||
{Icon}
|
||||
<span>{props.children}</span>
|
||||
</span>
|
||||
</BadgeWithTrend>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export const MobileAppNavigation = (
|
||||
props: React.PropsWithChildren<{
|
||||
slug: string;
|
||||
}>,
|
||||
) => {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const Links = getOrganizationAccountSidebarConfig(props.slug).routes.map(
|
||||
(item, index) => {
|
||||
if ('children' in item) {
|
||||
return item.children.map((child) => {
|
||||
return (
|
||||
<DropdownLink
|
||||
key={child.path}
|
||||
Icon={child.Icon}
|
||||
path={child.path}
|
||||
label={child.label}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ('divider' in item) {
|
||||
return <DropdownMenuSeparator key={index} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownLink
|
||||
key={item.path}
|
||||
Icon={item.Icon}
|
||||
path={item.path}
|
||||
label={item.label}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MenuIcon className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||
<OrganizationsModal />
|
||||
|
||||
{Links}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
function DropdownLink(
|
||||
props: React.PropsWithChildren<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem asChild key={props.path}>
|
||||
<Link
|
||||
href={props.path}
|
||||
className={'flex h-12 w-full items-center space-x-4'}
|
||||
>
|
||||
{props.Icon}
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={props.label} defaults={props.label} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutDropdownItem(
|
||||
props: React.PropsWithChildren<{
|
||||
onSignOut: () => unknown;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={'flex h-12 w-full items-center space-x-4'}
|
||||
onClick={props.onSignOut}
|
||||
>
|
||||
<LogOutIcon className={'h-6'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationsModal() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<DropdownMenuItem
|
||||
className={'h-12'}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<button className={'flex items-center space-x-4'}>
|
||||
<HomeIcon className={'h-6'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:yourOrganizations'} />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'common:yourOrganizations'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={'flex flex-col space-y-6 py-4'}>
|
||||
<AccountSelector
|
||||
onAccountChange={(value) => {
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
accounts={[]}
|
||||
features={{
|
||||
enableOrganizationAccounts:
|
||||
featureFlagsConfig.enableOrganizationAccounts,
|
||||
enableOrganizationCreation:
|
||||
featureFlagsConfig.enableOrganizationCreation,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import 'server-only';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
/**
|
||||
* Load the organization workspace data.
|
||||
* We place this function into a separate file so it can be reused in multiple places across the server components.
|
||||
*
|
||||
* This function is used in the layout component for the organization workspace.
|
||||
* It is cached so that the data is only fetched once per request.
|
||||
*
|
||||
* @param accountSlug
|
||||
*/
|
||||
export const loadOrganizationWorkspace = cache(async (accountSlug: string) => {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const accountPromise = client.rpc('organization_account_workspace', {
|
||||
account_slug: accountSlug,
|
||||
});
|
||||
|
||||
const accountsPromise = client.from('user_accounts').select('*');
|
||||
const userSessionPromise = client.auth.getSession();
|
||||
|
||||
const [accountResult, accountsResult, sessionResult] = await Promise.all([
|
||||
accountPromise,
|
||||
accountsPromise,
|
||||
userSessionPromise,
|
||||
]);
|
||||
|
||||
if (accountResult.error) {
|
||||
throw accountResult.error;
|
||||
}
|
||||
|
||||
// we cannot find any record for the selected organization
|
||||
// so we redirect the user to the home page
|
||||
if (!accountResult.data.length) {
|
||||
return redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
const accountData = accountResult.data[0];
|
||||
|
||||
if (!accountData) {
|
||||
return redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
if (accountsResult.error) {
|
||||
throw accountsResult.error;
|
||||
}
|
||||
|
||||
if (sessionResult.error ?? !sessionResult.data.session?.user) {
|
||||
throw new Error('User session not found');
|
||||
}
|
||||
|
||||
return {
|
||||
account: accountData,
|
||||
accounts: accountsResult.data,
|
||||
user: sessionResult.data.session.user,
|
||||
};
|
||||
});
|
||||
15
apps/web/app/(dashboard)/home/[account]/billing/layout.tsx
Normal file
15
apps/web/app/(dashboard)/home/[account]/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function OrganizationAccountBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enableOrganizationBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default OrganizationAccountBillingLayout;
|
||||
19
apps/web/app/(dashboard)/home/[account]/billing/page.tsx
Normal file
19
apps/web/app/(dashboard)/home/[account]/billing/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function OrganizationAccountBillingPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:billingTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationAccountBillingPage);
|
||||
50
apps/web/app/(dashboard)/home/[account]/layout.tsx
Normal file
50
apps/web/app/(dashboard)/home/[account]/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
|
||||
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
import { AppSidebar } from './(components)/app-sidebar';
|
||||
import { loadOrganizationWorkspace } from './(lib)/load-workspace';
|
||||
|
||||
interface Params {
|
||||
account: string;
|
||||
}
|
||||
|
||||
async function OrganizationWorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: React.PropsWithChildren<{
|
||||
params: Params;
|
||||
}>) {
|
||||
const data = await loadOrganizationWorkspace(params.account);
|
||||
const ui = getUIStateCookies();
|
||||
const sidebarCollapsed = ui.sidebarState === 'collapsed';
|
||||
|
||||
return (
|
||||
<Page
|
||||
sidebar={
|
||||
<AppSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
account={params.account}
|
||||
accounts={data.accounts.map(({ name, slug, picture_url }) => ({
|
||||
label: name,
|
||||
value: slug,
|
||||
image: picture_url,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationWorkspaceLayout);
|
||||
|
||||
function getUIStateCookies() {
|
||||
return {
|
||||
theme: parseThemeCookie(),
|
||||
sidebarState: parseSidebarStateCookie(),
|
||||
};
|
||||
}
|
||||
3
apps/web/app/(dashboard)/home/[account]/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/[account]/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
144
apps/web/app/(dashboard)/home/[account]/members/page.tsx
Normal file
144
apps/web/app/(dashboard)/home/[account]/members/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { PlusCircledIcon } from '@radix-ui/react-icons';
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import {
|
||||
AccountInvitationsTable,
|
||||
AccountMembersTable,
|
||||
InviteMembersDialogContainer,
|
||||
} from '@kit/team-accounts/components';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAccountMembers(account: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const { data, error } = await client.rpc('get_account_members', {
|
||||
account_slug: account,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async function loadInvitations(account: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const { data, error } = await client.rpc('get_account_invitations', {
|
||||
account_slug: account,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async function OrganizationAccountMembersPage({ params }: Params) {
|
||||
const slug = params.account;
|
||||
|
||||
const [{ account, user }, members, invitations] = await Promise.all([
|
||||
loadOrganizationWorkspace(slug),
|
||||
loadAccountMembers(slug),
|
||||
loadInvitations(slug),
|
||||
]);
|
||||
|
||||
const canManageRoles = account.permissions.includes('roles.manage');
|
||||
const isPrimaryOwner = account.primary_owner_user_id === user.id;
|
||||
|
||||
const permissions = {
|
||||
canUpdateRole: canManageRoles,
|
||||
canRemoveFromAccount: canManageRoles,
|
||||
canTransferOwnership: isPrimaryOwner,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'common:membersTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:membersTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div
|
||||
className={'mx-auto flex w-full max-w-3xl flex-col space-y-4 pb-32'}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className={'flex flex-row justify-between'}>
|
||||
<div className={'flex flex-col space-y-1.5'}>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'common:membersTabLabel'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
Here you can manage the members of your organization.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<InviteMembersDialogContainer account={account.slug}>
|
||||
<Button size={'sm'}>
|
||||
<PlusCircledIcon className={'mr-2 w-4'} />
|
||||
<span>Add Member</span>
|
||||
</Button>
|
||||
</InviteMembersDialogContainer>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountMembersTable
|
||||
currentUserId={user.id}
|
||||
permissions={permissions}
|
||||
members={members}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className={'flex flex-row justify-between'}>
|
||||
<div className={'flex flex-col space-y-1.5'}>
|
||||
<CardTitle>Pending Invitations</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
Here you can manage the pending invitations to your
|
||||
organization.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountInvitationsTable
|
||||
permissions={{
|
||||
canUpdateInvitation: canManageRoles,
|
||||
canRemoveInvitation: canManageRoles,
|
||||
}}
|
||||
invitations={invitations}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationAccountMembersPage);
|
||||
64
apps/web/app/(dashboard)/home/[account]/page.tsx
Normal file
64
apps/web/app/(dashboard)/home/[account]/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import loadDynamic from 'next/dynamic';
|
||||
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import Spinner from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const DashboardDemo = loadDynamic(
|
||||
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={
|
||||
'flex h-full flex-1 flex-col items-center justify-center space-y-4' +
|
||||
' py-24'
|
||||
}
|
||||
>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common:loading'} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export const metadata = {
|
||||
title: 'Organization Account Home',
|
||||
};
|
||||
|
||||
function OrganizationAccountHomePage({
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader
|
||||
title={<Trans i18nKey={'common:dashboardTabLabel'} />}
|
||||
description={<Trans i18nKey={'common:dashboardTabDescription'} />}
|
||||
account={params.account}
|
||||
>
|
||||
<Button>
|
||||
<PlusIcon className={'mr-2 h-4'} />
|
||||
<span>Add Widget</span>
|
||||
</Button>
|
||||
</AppHeader>
|
||||
|
||||
<PageBody>
|
||||
<DashboardDemo />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationAccountHomePage);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
87
apps/web/app/(dashboard)/home/[account]/settings/page.tsx
Normal file
87
apps/web/app/(dashboard)/home/[account]/settings/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import {
|
||||
TeamAccountDangerZone,
|
||||
UpdateOrganizationForm,
|
||||
} from '@kit/team-accounts/components';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Organization Settings',
|
||||
};
|
||||
|
||||
const allowOrganizationDelete = featureFlagsConfig.enableOrganizationDeletion;
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
function OrganizationSettingsPage({ params }: Params) {
|
||||
const { account, user } = use(loadOrganizationWorkspace(params.account));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey={'organization:settingsPageTitle'} />}
|
||||
description={<Trans i18nKey={'organization:settingsPageDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div className={'mx-auto flex max-w-5xl flex-col space-y-4'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'organization:generalTabLabel'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'organization:generalTabLabelSubheading'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateOrganizationForm
|
||||
accountId={account.id}
|
||||
accountName={account.name}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={allowOrganizationDelete}>
|
||||
<Card className={'border-2 border-destructive'}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'organization:dangerZone'} />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'organization:dangerZoneSubheading'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<TeamAccountDangerZone userId={user.id} account={account} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationSettingsPage);
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowUpRightIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function BillingPortalRedirectButton({
|
||||
children,
|
||||
customerId,
|
||||
className,
|
||||
}: React.PropsWithChildren<{
|
||||
customerId: string;
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<form action={createBillingPortalSessionAction}>
|
||||
<input type={'hidden'} name={'customerId'} value={customerId} />
|
||||
|
||||
<Button
|
||||
data-test={'manage-billing-redirect-button'}
|
||||
variant={'outline'}
|
||||
className={className}
|
||||
>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>{children}</span>
|
||||
|
||||
<ArrowUpRightIcon className={'h-3'} />
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function CheckoutRedirectButton({
|
||||
children,
|
||||
onCheckoutCreated,
|
||||
...props
|
||||
}): React.PropsWithChildren<{
|
||||
disabled?: boolean;
|
||||
stripePriceId?: string;
|
||||
recommended?: boolean;
|
||||
organizationUid: string;
|
||||
onCheckoutCreated?: (clientSecret: string) => void;
|
||||
}> {
|
||||
const [state, formAction] = useFormState(createCheckoutAction, {
|
||||
clientSecret: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.clientSecret && onCheckoutCreated) {
|
||||
onCheckoutCreated(state.clientSecret);
|
||||
}
|
||||
}, [state.clientSecret, onCheckoutCreated]);
|
||||
|
||||
return (
|
||||
<form data-test={'checkout-form'} action={formAction}>
|
||||
<CheckoutFormData
|
||||
organizationUid={props.organizationUid}
|
||||
priceId={props.stripePriceId}
|
||||
/>
|
||||
|
||||
<SubmitCheckoutButton
|
||||
disabled={props.disabled}
|
||||
recommended={props.recommended}
|
||||
>
|
||||
{children}
|
||||
</SubmitCheckoutButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitCheckoutButton(
|
||||
props: React.PropsWithChildren<{
|
||||
recommended?: boolean;
|
||||
disabled?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn({
|
||||
'bg-primary text-primary-foreground dark:bg-white dark:text-gray-900':
|
||||
props.recommended,
|
||||
})}
|
||||
variant={props.recommended ? 'custom' : 'outline'}
|
||||
disabled={props.disabled ?? pending}
|
||||
>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>{props.children}</span>
|
||||
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutFormData(
|
||||
props: React.PropsWithChildren<{
|
||||
organizationUid: string | undefined;
|
||||
priceId: string | undefined;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name={'organizationUid'}
|
||||
defaultValue={props.organizationUid}
|
||||
/>
|
||||
|
||||
<input type="hidden" name={'returnUrl'} defaultValue={getReturnUrl()} />
|
||||
<input type="hidden" name={'priceId'} defaultValue={props.priceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getReturnUrl() {
|
||||
return isBrowser()
|
||||
? [window.location.origin, window.location.pathname].join('')
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog';
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import pricingConfig, {
|
||||
StripeCheckoutDisplayMode,
|
||||
} from '@/config/pricing.config';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import LogoImage from '@/components/app/Logo/LogoImage';
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!STRIPE_PUBLISHABLE_KEY) {
|
||||
throw new Error(
|
||||
'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
|
||||
);
|
||||
}
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
|
||||
|
||||
export default function EmbeddedStripeCheckout({
|
||||
clientSecret,
|
||||
onClose,
|
||||
}: React.PropsWithChildren<{
|
||||
clientSecret: string;
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<EmbeddedCheckoutPopup key={clientSecret} onClose={onClose}>
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{ clientSecret }}
|
||||
>
|
||||
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</EmbeddedCheckoutPopup>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedCheckoutPopup({
|
||||
onClose,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const displayMode = pricingConfig.displayMode;
|
||||
const isPopup = displayMode === StripeCheckoutDisplayMode.Popup;
|
||||
const isOverlay = displayMode === StripeCheckoutDisplayMode.Overlay;
|
||||
|
||||
const className = cn({
|
||||
[`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
|
||||
isPopup,
|
||||
[`bg-background !flex flex-col flex-1 fixed top-0 !max-h-full !max-w-full left-0 w-screen h-screen border-transparent shadow-transparent py-4 px-8`]:
|
||||
isOverlay,
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
defaultOpen
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<If condition={isOverlay}>
|
||||
<div className={'mb-8'}>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<LogoImage />
|
||||
|
||||
<Button onClick={close} variant={'outline'}>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={isPopup}>
|
||||
<DialogPrimitiveClose asChild>
|
||||
<Button
|
||||
size={'icon'}
|
||||
className={'absolute right-4 top-2 flex items-center'}
|
||||
aria-label={'Close Checkout'}
|
||||
onClick={close}
|
||||
>
|
||||
<XIcon className={'h-6 text-gray-900'} />
|
||||
|
||||
<span className="sr-only">
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</span>
|
||||
</Button>
|
||||
</DialogPrimitiveClose>
|
||||
</If>
|
||||
|
||||
<div
|
||||
className={cn({
|
||||
[`flex-1 rounded-xl bg-white p-8`]: isOverlay,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
import If from '@/components/app/If';
|
||||
import PricingTable from '@/components/app/PricingTable';
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
import BillingPortalRedirectButton from './billing-redirect-button';
|
||||
import CheckoutRedirectButton from './checkout-redirect-button';
|
||||
|
||||
const EmbeddedStripeCheckout = dynamic(
|
||||
() => import('./embedded-stripe-checkout'),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const PlanSelectionForm: React.FC<{
|
||||
organization: WithId<Organization>;
|
||||
customerId: Maybe<string>;
|
||||
}> = ({ organization, customerId }) => {
|
||||
const [clientSecret, setClientSecret] = useState<string>();
|
||||
const [retry, setRetry] = useState(0);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<If condition={clientSecret}>
|
||||
<EmbeddedStripeCheckout clientSecret={clientSecret!} />
|
||||
</If>
|
||||
|
||||
<div className={'flex w-full flex-col justify-center space-y-8'}>
|
||||
<PricingTable
|
||||
CheckoutButton={(props) => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
key={retry}
|
||||
fallback={
|
||||
<CheckoutErrorMessage
|
||||
onRetry={() => setRetry((retry) => retry + 1)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CheckoutRedirectButton
|
||||
organizationUid={organization.uuid}
|
||||
stripePriceId={props.stripePriceId}
|
||||
recommended={props.recommended}
|
||||
onCheckoutCreated={setClientSecret}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'subscription:checkout'}
|
||||
defaults={'Checkout'}
|
||||
/>
|
||||
</CheckoutRedirectButton>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<If condition={customerId}>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<BillingPortalRedirectButton customerId={customerId as string}>
|
||||
<Trans i18nKey={'subscription:manageBilling'} />
|
||||
</BillingPortalRedirectButton>
|
||||
|
||||
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
|
||||
<Trans i18nKey={'subscription:manageBillingDescription'} />
|
||||
</span>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanSelectionForm;
|
||||
|
||||
function NoPermissionsAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'subscription:noPermissionsAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'subscription:noPermissionsAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutErrorMessage({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'text-sm font-medium text-red-500'}>
|
||||
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
|
||||
</span>
|
||||
|
||||
<Button onClick={onRetry} variant={'ghost'}>
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
enum SubscriptionStatusQueryParams {
|
||||
Success = 'success',
|
||||
Cancel = 'cancel',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
function PlansStatusAlertContainer() {
|
||||
const status = useSubscriptionStatus();
|
||||
|
||||
if (status === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PlansStatusAlert status={status as SubscriptionStatusQueryParams} />;
|
||||
}
|
||||
|
||||
export default PlansStatusAlertContainer;
|
||||
|
||||
function PlansStatusAlert({
|
||||
status,
|
||||
}: {
|
||||
status: SubscriptionStatusQueryParams;
|
||||
}) {
|
||||
switch (status) {
|
||||
case SubscriptionStatusQueryParams.Cancel:
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'subscription:checkOutCanceledAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'subscription:checkOutCanceledAlert'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case SubscriptionStatusQueryParams.Error:
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'subscription:unknownErrorAlert'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case SubscriptionStatusQueryParams.Success:
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'subscription:checkOutCompletedAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'subscription:checkOutCompletedAlert'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function useSubscriptionStatus() {
|
||||
const params = useSearchParams();
|
||||
|
||||
return getStatus(params);
|
||||
}
|
||||
|
||||
function getStatus(params: ReadonlyURLSearchParams | null) {
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = params.has(SubscriptionStatusQueryParams.Error);
|
||||
const canceled = params.has(SubscriptionStatusQueryParams.Cancel);
|
||||
const success = params.has(SubscriptionStatusQueryParams.Success);
|
||||
|
||||
if (canceled) {
|
||||
return SubscriptionStatusQueryParams.Cancel;
|
||||
} else if (success) {
|
||||
return SubscriptionStatusQueryParams.Success;
|
||||
} else if (error) {
|
||||
return SubscriptionStatusQueryParams.Error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import useCurrentOrganization from '@/lib/organizations/hooks/use-current-organization';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
import BillingPortalRedirectButton from './billing-redirect-button';
|
||||
import PlanSelectionForm from './plan-selection-form';
|
||||
import SubscriptionCard from './subscription-card';
|
||||
|
||||
const PlansContainer: React.FC = () => {
|
||||
const organization = useCurrentOrganization();
|
||||
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customerId = organization.subscription?.customerId;
|
||||
const subscription = organization.subscription?.data;
|
||||
|
||||
if (!subscription) {
|
||||
return (
|
||||
<PlanSelectionForm customerId={customerId} organization={organization} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<div
|
||||
className={'w-full divide-y rounded-xl border lg:w-9/12 xl:w-6/12'}
|
||||
>
|
||||
<div className={'p-6'}>
|
||||
<SubscriptionCard subscription={subscription} />
|
||||
</div>
|
||||
|
||||
<If condition={customerId}>
|
||||
<div className={'flex justify-end p-6'}>
|
||||
<div className={'flex flex-col items-end space-y-2'}>
|
||||
<BillingPortalRedirectButton customerId={customerId as string}>
|
||||
<Trans i18nKey={'subscription:manageBilling'} />
|
||||
</BillingPortalRedirectButton>
|
||||
|
||||
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
|
||||
<Trans i18nKey={'subscription:manageBillingDescription'} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlansContainer;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import Heading from '@/components/ui/heading';
|
||||
import { CheckCircleIcon, XCircleIcon } from 'lucide-react';
|
||||
import { getI18n } from 'react-i18next';
|
||||
import SubscriptionStatusBadge from '~/(dashboard)/home/[account]/(components)/organizations/SubscriptionStatusBadge';
|
||||
|
||||
import pricingConfig from '@/config/pricing.config';
|
||||
|
||||
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import PricingTable from '@/components/app/PricingTable';
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
import SubscriptionStatusAlert from './subscription-status-alert';
|
||||
|
||||
const SubscriptionCard: React.FC<{
|
||||
subscription: OrganizationSubscription;
|
||||
}> = ({ subscription }) => {
|
||||
const details = useSubscriptionDetails(subscription.priceId);
|
||||
const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd;
|
||||
const isActive = subscription.status === 'active';
|
||||
const language = getI18n().language;
|
||||
|
||||
const dates = useMemo(() => {
|
||||
const endDate = new Date(subscription.periodEndsAt);
|
||||
const trialEndDate =
|
||||
subscription.trialEndsAt && new Date(subscription.trialEndsAt);
|
||||
|
||||
return {
|
||||
endDate: endDate.toLocaleDateString(language),
|
||||
trialEndDate: trialEndDate
|
||||
? trialEndDate.toLocaleDateString(language)
|
||||
: null,
|
||||
};
|
||||
}, [language, subscription]);
|
||||
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'flex space-x-2'}
|
||||
data-test={'subscription-card'}
|
||||
data-test-status={subscription.status}
|
||||
>
|
||||
<div className={'flex w-9/12 flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Heading level={4}>
|
||||
<span data-test={'subscription-name'}>
|
||||
{details.product.name}
|
||||
</span>
|
||||
</Heading>
|
||||
|
||||
<div>
|
||||
<SubscriptionStatusBadge subscription={subscription} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
{details.product.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<If condition={isActive}>
|
||||
<RenewStatusDescription
|
||||
dates={dates}
|
||||
cancelAtPeriodEnd={cancelAtPeriodEnd}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<SubscriptionStatusAlert subscription={subscription} values={dates} />
|
||||
</div>
|
||||
|
||||
<div className={'w-3/12'}>
|
||||
<span className={'flex items-center justify-end space-x-1'}>
|
||||
<PricingTable.Price>{details.plan.price}</PricingTable.Price>
|
||||
|
||||
<span className={'lowercase text-gray-500 dark:text-gray-400'}>
|
||||
/{details.plan.name}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RenewStatusDescription(
|
||||
props: React.PropsWithChildren<{
|
||||
cancelAtPeriodEnd: boolean;
|
||||
dates: {
|
||||
endDate: string;
|
||||
trialEndDate: string | null;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<span className={'flex items-center space-x-1.5 text-sm'}>
|
||||
<If condition={props.cancelAtPeriodEnd}>
|
||||
<XCircleIcon className={'h-5 text-yellow-700'} />
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'subscription:cancelAtPeriodEndDescription'}
|
||||
values={props.dates}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={!props.cancelAtPeriodEnd}>
|
||||
<CheckCircleIcon className={'h-5 text-green-700'} />
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'subscription:renewAtPeriodEndDescription'}
|
||||
values={props.dates}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useSubscriptionDetails(priceId: string) {
|
||||
const products = pricingConfig.products;
|
||||
|
||||
return useMemo(() => {
|
||||
for (const product of products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.stripePriceId === priceId) {
|
||||
return { plan, product };
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [products, priceId]);
|
||||
}
|
||||
|
||||
export default SubscriptionCard;
|
||||
@@ -0,0 +1,68 @@
|
||||
import classNames from 'clsx';
|
||||
|
||||
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
|
||||
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
function SubscriptionStatusAlert(
|
||||
props: React.PropsWithChildren<{
|
||||
subscription: OrganizationSubscription;
|
||||
values: {
|
||||
endDate: string;
|
||||
trialEndDate: string | null;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const status = props.subscription.status;
|
||||
|
||||
let message = '';
|
||||
let type: 'success' | 'error' | 'warn';
|
||||
|
||||
switch (status) {
|
||||
case 'active':
|
||||
message = 'subscription:status.active.description';
|
||||
type = 'success';
|
||||
break;
|
||||
case 'trialing':
|
||||
message = 'subscription:status.trialing.description';
|
||||
type = 'success';
|
||||
break;
|
||||
case 'canceled':
|
||||
message = 'subscription:status.canceled.description';
|
||||
type = 'warn';
|
||||
break;
|
||||
case 'incomplete':
|
||||
message = 'subscription:status.incomplete.description';
|
||||
type = 'warn';
|
||||
break;
|
||||
case 'incomplete_expired':
|
||||
message = 'subscription:status.incomplete_expired.description';
|
||||
type = 'error';
|
||||
break;
|
||||
case 'unpaid':
|
||||
message = 'subscription:status.unpaid.description';
|
||||
type = 'error';
|
||||
break;
|
||||
case 'past_due':
|
||||
message = 'subscription:status.past_due.description';
|
||||
type = 'error';
|
||||
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames('text-sm', {
|
||||
'text-orange-700 dark:text-gray-400': type === 'warn',
|
||||
'text-red-700 dark:text-red-400': type === 'error',
|
||||
'text-green-700 dark:text-green-400': type === 'success',
|
||||
})}
|
||||
>
|
||||
<Trans i18nKey={message} values={props.values} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default SubscriptionStatusAlert;
|
||||
@@ -0,0 +1,34 @@
|
||||
import Heading from '@/components/ui/heading';
|
||||
|
||||
import { withI18n } from '@packages/i18n/with-i18n';
|
||||
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
import PlansStatusAlertContainer from './components/plan-status-alert-container';
|
||||
import PlansContainer from './components/plans-container';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Subscription',
|
||||
};
|
||||
|
||||
const SubscriptionSettingsPage = () => {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1 px-2'}>
|
||||
<Heading level={4}>
|
||||
<Trans i18nKey={'common:subscriptionSettingsTabLabel'} />
|
||||
</Heading>
|
||||
|
||||
<span className={'text-gray-500 dark:text-gray-400'}>
|
||||
<Trans i18nKey={'subscription:subscriptionTabSubheading'} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PlansStatusAlertContainer />
|
||||
|
||||
<PlansContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withI18n(SubscriptionSettingsPage);
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import type { Stripe } from 'stripe';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
/**
|
||||
* Retrieves the session status for a Stripe checkout session.
|
||||
* Since we should only arrive here for a successful checkout, we only check
|
||||
* for the `paid` status.
|
||||
*
|
||||
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
|
||||
* @param {string} customerEmail - The email address of the customer associated with the session.
|
||||
*
|
||||
* @returns {ReactElement} - The component to render based on the session status.
|
||||
*/
|
||||
export function BillingSessionStatus({
|
||||
customerEmail,
|
||||
}: React.PropsWithChildren<{
|
||||
status: Stripe.Checkout.Session['status'];
|
||||
customerEmail: string;
|
||||
}>) {
|
||||
return <SuccessSessionStatus customerEmail={customerEmail} />;
|
||||
}
|
||||
|
||||
function SuccessSessionStatus({
|
||||
customerEmail,
|
||||
}: React.PropsWithChildren<{
|
||||
customerEmail: string;
|
||||
}>) {
|
||||
return (
|
||||
<section
|
||||
data-test={'payment-return-success'}
|
||||
className={
|
||||
'mx-auto max-w-xl rounded-xl border p-16 fade-in xl:drop-shadow-sm' +
|
||||
' dark:border-dark-800 border-gray-100' +
|
||||
' bg-background ease-out animate-in slide-in-from-bottom-8' +
|
||||
' duration-1000 zoom-in-50 dark:shadow-2xl dark:shadow-primary/40'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-4 text-center'
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={
|
||||
'w-16 rounded-full bg-green-500 p-1 text-white ring-8' +
|
||||
' ring-green-500/30 dark:ring-green-500/50'
|
||||
}
|
||||
/>
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'mr-4 font-semibold'}>
|
||||
<Trans i18nKey={'subscription:checkoutSuccessTitle'} />
|
||||
</span>
|
||||
🎉
|
||||
</Heading>
|
||||
|
||||
<div
|
||||
className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'subscription:checkoutSuccessDescription'}
|
||||
values={{ customerEmail }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button data-test={'checkout-success-back-button'} variant={'outline'}>
|
||||
<Link href={pathsConfig.app.home}>
|
||||
<span className={'flex items-center space-x-2.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'subscription:checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const EmbeddedStripeCheckout = dynamic(
|
||||
() => {
|
||||
return import('../../components/embedded-stripe-checkout');
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
function RecoverCheckout({ clientSecret }: { clientSecret: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<EmbeddedStripeCheckout
|
||||
clientSecret={clientSecret}
|
||||
onClose={() => {
|
||||
return router.replace('/settings/subscription');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecoverCheckout;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { withI18n } from '@packages/i18n/with-i18n';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import createStripeClient from '@kit/stripe/get-stripe';
|
||||
|
||||
import requireSession from '@/lib/user/require-session';
|
||||
|
||||
import { BillingSessionStatus } from './components/billing-session-status';
|
||||
import RecoverCheckout from './components/recover-checkout';
|
||||
|
||||
interface SessionPageProps {
|
||||
searchParams: {
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function ReturnStripeSessionPage({ searchParams }: SessionPageProps) {
|
||||
const { status, customerEmail, clientSecret } = await loadStripeSession(
|
||||
searchParams.session_id,
|
||||
);
|
||||
|
||||
if (clientSecret) {
|
||||
return <RecoverCheckout clientSecret={clientSecret} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
||||
<BillingSessionStatus
|
||||
status={status}
|
||||
customerEmail={customerEmail ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ReturnStripeSessionPage);
|
||||
|
||||
export async function loadStripeSession(sessionId: string) {
|
||||
await requireSession(getSupabaseServerComponentClient());
|
||||
|
||||
// now we fetch the session from Stripe
|
||||
// and check if it's still open
|
||||
const stripe = await createStripeClient();
|
||||
|
||||
const session = await stripe.checkout.sessions
|
||||
.retrieve(sessionId)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isSessionOpen = session.status === 'open';
|
||||
const clientSecret = isSessionOpen ? session.client_secret : null;
|
||||
const isEmbeddedMode = session.ui_mode === 'embedded';
|
||||
|
||||
// if the session is still open, we redirect the user to the checkout page
|
||||
// in Stripe self hosted mode
|
||||
if (isSessionOpen && !isEmbeddedMode && session.url) {
|
||||
redirect(session.url);
|
||||
}
|
||||
|
||||
// otherwise - we show the user the return page
|
||||
// and display the details of the session
|
||||
return {
|
||||
status: session.status,
|
||||
customerEmail: session.customer_details?.email,
|
||||
clientSecret,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
|
||||
const features = {
|
||||
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
|
||||
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,
|
||||
};
|
||||
|
||||
export function HomeSidebarAccountSelector(props: {
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
}>;
|
||||
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
collapsed={props.collapsed}
|
||||
accounts={props.accounts}
|
||||
features={features}
|
||||
onAccountChange={(value) => {
|
||||
if (value) {
|
||||
const path = pathsConfig.app.accountHome.replace('[account]', value);
|
||||
router.replace(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/web/app/(dashboard)/home/components/home-sidebar.tsx
Normal file
57
apps/web/app/(dashboard)/home/components/home-sidebar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector';
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
|
||||
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
|
||||
|
||||
export function HomeSidebar() {
|
||||
const collapsed = getSidebarCollapsed();
|
||||
const accounts = use(loadUserAccounts());
|
||||
|
||||
return (
|
||||
<Sidebar collapsed={collapsed}>
|
||||
<SidebarContent className={'my-4'}>
|
||||
<HomeSidebarAccountSelector collapsed={collapsed} accounts={accounts} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent className={`h-[calc(100%-160px)] overflow-y-auto`}>
|
||||
<SidebarNavigation config={personalAccountSidebarConfig} />
|
||||
</SidebarContent>
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<ProfileDropdownContainer collapsed={collapsed} />
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function getSidebarCollapsed() {
|
||||
return cookies().get('sidebar-collapsed')?.value === 'true';
|
||||
}
|
||||
|
||||
async function loadUserAccounts() {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const { data: accounts, error } = await client
|
||||
.from('user_accounts')
|
||||
.select('*');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return accounts.map(({ name, slug, picture_url }) => {
|
||||
return {
|
||||
label: name,
|
||||
value: slug,
|
||||
image: picture_url,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
|
||||
export function ProfileDropdownContainer(props: { collapsed: boolean }) {
|
||||
const userSession = useUserSession();
|
||||
const signOut = useSignOut();
|
||||
const session = userSession?.data ?? undefined;
|
||||
|
||||
return (
|
||||
<div className={props.collapsed ? '' : 'w-full'}>
|
||||
<PersonalAccountDropdown
|
||||
paths={{
|
||||
home: pathsConfig.app.home,
|
||||
}}
|
||||
className={'w-full'}
|
||||
showProfileName={!props.collapsed}
|
||||
session={session}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/web/app/(dashboard)/home/loading.tsx
Normal file
3
apps/web/app/(dashboard)/home/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
93
apps/web/app/(marketing)/about/page.tsx
Normal file
93
apps/web/app/(marketing)/about/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
export const metadata = {
|
||||
title: 'About',
|
||||
};
|
||||
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-14'}>
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<Heading level={1}>About us</Heading>
|
||||
|
||||
<Heading level={2}>
|
||||
We are a team of passionate developers and designers who love to
|
||||
build great products.
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'm-auto flex w-full max-w-xl flex-col items-center space-y-8' +
|
||||
' justify-center text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
We are a team of visionaries, dreamers, and doers who are on a
|
||||
mission to change the world for the better
|
||||
</div>
|
||||
|
||||
<div>
|
||||
With a passion for innovation and a commitment to excellence, we
|
||||
are dedicated to creating products and services that will improve
|
||||
people's lives and make a positive impact on society.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
It all started with a simple idea: to use technology to solve some
|
||||
of the biggest challenges facing humanity. We realized that with
|
||||
the right team and the right approach, we could make a difference
|
||||
and leave a lasting legacy. And so, with a lot of hard work and
|
||||
determination, we set out on a journey to turn our vision into
|
||||
reality.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Today, we are proud to be a leader in our field, and our products
|
||||
and services are used by millions of people all over the world.
|
||||
But we're not done yet. We still have big dreams and even
|
||||
bigger plans, and we're always looking for ways to push the
|
||||
boundaries of what's possible.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Our Values: At the heart of everything we do is a set of core
|
||||
values that guide us in all that we do. These values are what make
|
||||
us who we are, and they are what set us apart from the rest.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul className={'flex list-decimal flex-col space-y-1 pl-4'}>
|
||||
<li>
|
||||
Innovation: We are always looking for new and better ways to
|
||||
do things.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Excellence: We strive for excellence in all that we do, and we
|
||||
never settle for less.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Responsibility: We take our responsibilities seriously, and we
|
||||
always act with integrity.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Collaboration: We believe that by working together, we can
|
||||
achieve more than we can on our own.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>Yes, this was generated with ChatGPT</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import Post from '../components/post';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata | undefined> {
|
||||
const post = allPosts.find((post) => post.slug === params.slug);
|
||||
|
||||
if (!post) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, date, description, image, slug } = post;
|
||||
const url = [appConfig.url, 'blog', slug].join('/');
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: date,
|
||||
url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function BlogPost({ params }: { params: { slug: string } }) {
|
||||
const post = allPosts.find((post) => post.slug === params.slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<Script id={'ld-json'} type="application/ld+json">
|
||||
{JSON.stringify(post.structuredData)}
|
||||
</Script>
|
||||
|
||||
<Post post={post} content={post.body.code} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPost);
|
||||
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal file
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
src: string;
|
||||
preloadImage?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CoverImage: React.FC<Props> = ({
|
||||
title,
|
||||
src,
|
||||
preloadImage,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Image
|
||||
className={cn(
|
||||
'duration-250 block rounded-xl object-cover' +
|
||||
' transition-all hover:opacity-90',
|
||||
{
|
||||
className,
|
||||
},
|
||||
)}
|
||||
src={src}
|
||||
priority={preloadImage}
|
||||
alt={`Cover Image for ${title}`}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
};
|
||||
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal file
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
export const DateFormatter = ({ dateString }: Props) => {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DraftPostBadge({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal file
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
const PostHeader: React.FC<{
|
||||
post: Post;
|
||||
}> = ({ post }) => {
|
||||
const { title, date, readingTime, description, image } = post;
|
||||
|
||||
// NB: change this to display the post's image
|
||||
const displayImage = true;
|
||||
const preloadImage = true;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'font-normal text-muted-foreground'}>
|
||||
{description}
|
||||
</span>
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="flex flex-row items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<DateFormatter dateString={date} />
|
||||
</div>
|
||||
|
||||
<span>·</span>
|
||||
<span>{readingTime} minutes reading</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={displayImage && image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mx-auto h-[378px] w-full justify-center">
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
className="rounded-md"
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostHeader;
|
||||
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal file
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
preloadImage?: boolean;
|
||||
imageHeight?: string | number;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_HEIGHT = 250;
|
||||
|
||||
function PostPreview({
|
||||
post,
|
||||
preloadImage,
|
||||
imageHeight,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const { title, image, date, readingTime, description } = post;
|
||||
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl transition-shadow duration-500 dark:text-gray-800">
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mb-2 w-full" style={{ height }}>
|
||||
<Link href={post.url}>
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<div className={'px-1'}>
|
||||
<div className="flex flex-col space-y-1 px-1 py-2">
|
||||
<h3 className="px-1 text-2xl font-bold leading-snug dark:text-white">
|
||||
<Link href={post.url} className="hover:underline">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-row items-center space-x-2 px-1 text-sm">
|
||||
<div className="text-gray-600 dark:text-gray-300">
|
||||
<DateFormatter dateString={date} />
|
||||
</div>
|
||||
|
||||
<span className="text-gray-600 dark:text-gray-300">·</span>
|
||||
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{readingTime} mins reading
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 px-1 text-sm leading-relaxed dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostPreview;
|
||||
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal file
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Post as PostType } from 'contentlayer/generated';
|
||||
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
import PostHeader from './post-header';
|
||||
|
||||
export const Post: React.FC<{
|
||||
post: PostType;
|
||||
content: string;
|
||||
}> = ({ post, content }) => {
|
||||
return (
|
||||
<div className={'mx-auto my-8 max-w-2xl'}>
|
||||
<PostHeader post={post} />
|
||||
|
||||
<article className={'mx-auto flex justify-center'}>
|
||||
<Mdx code={content} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
41
apps/web/app/(marketing)/blog/page.tsx
Normal file
41
apps/web/app/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
import PostPreview from '~/(marketing)/blog/components/post-preview';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { GridList } from '../components/grid-list';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Blog - ${appConfig.name}`,
|
||||
description: `Tutorials, Guides and Updates from our team`,
|
||||
};
|
||||
|
||||
async function BlogPage() {
|
||||
const livePosts = allPosts.filter((post) => {
|
||||
const isProduction = appConfig.production;
|
||||
|
||||
return isProduction ? post.live : true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Blog'}
|
||||
subtitle={'Tutorials, Guides and Updates from our team'}
|
||||
/>
|
||||
|
||||
<GridList>
|
||||
{livePosts.map((post, idx) => {
|
||||
return <PostPreview key={idx} post={post} />;
|
||||
})}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPage);
|
||||
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal file
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function GridList({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="mb-16 grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-8 md:gap-y-12 lg:grid-cols-3 lg:gap-x-12">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal file
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className={'py-8 lg:py-24'}>
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex flex-col space-y-8 lg:flex-row lg:space-y-0'}>
|
||||
<div
|
||||
className={
|
||||
'flex w-full space-x-2 lg:w-4/12 xl:w-3/12' +
|
||||
' xl:space-x-6 2xl:space-x-8'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<AppLogo className={'w-[85px] md:w-[115px]'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
Add a short tagline about your product
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex text-xs text-gray-500 dark:text-gray-400'}>
|
||||
<p>
|
||||
© Copyright {YEAR} {appConfig.name}. All Rights Reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col space-y-8 lg:space-x-6 lg:space-y-0' +
|
||||
' xl:space-x-16 2xl:space-x-20' +
|
||||
' w-full lg:flex-row lg:justify-end'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>About</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Who we are</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'/blog'}>Blog</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'/contact'}>Contact</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>Product</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'/docs'}>Documentation</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Help Center</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Changelog</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>Legal</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Terms of Service</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Privacy Policy</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Cookie Policy</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSectionHeading(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<p>
|
||||
<span className={'font-semibold'}>{props.children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSectionList(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
'text-sm [&>a]:transition-colors [&>a]:hover:text-gray-800' +
|
||||
' dark:[&>a]:hover:text-white'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function SiteHeaderAccountSection() {
|
||||
const signOut = useSignOut();
|
||||
const userSession = useUserSession();
|
||||
|
||||
if (userSession.data) {
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
session={userSession.data}
|
||||
paths={{
|
||||
home: pathsConfig.app.home,
|
||||
}}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthButtons />;
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
return (
|
||||
<div className={'hidden space-x-2 lg:flex'}>
|
||||
<Button variant={'link'}>
|
||||
<Link href={pathsConfig.auth.signIn}>Sign In</Link>
|
||||
</Button>
|
||||
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Button className={'rounded-full'}>
|
||||
Sign Up
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/app/(marketing)/components/site-header.tsx
Normal file
29
apps/web/app/(marketing)/components/site-header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { SiteHeaderAccountSection } from '~/(marketing)/components/site-header-account-section';
|
||||
import { SiteNavigation } from '~/(marketing)/components/site-navigation';
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className={'w-4/12'}>
|
||||
<AppLogo />
|
||||
</div>
|
||||
|
||||
<div className={'hidden w-4/12 justify-center lg:flex'}>
|
||||
<SiteNavigation />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-1 items-center justify-end space-x-4'}>
|
||||
<div className={'flex items-center'}></div>
|
||||
|
||||
<SiteHeaderAccountSection />
|
||||
|
||||
<div className={'flex lg:hidden'}>
|
||||
<SiteNavigation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal file
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
} from '@kit/ui/navigation-menu';
|
||||
|
||||
const links = {
|
||||
SignIn: {
|
||||
label: 'Sign In',
|
||||
path: '/auth/sign-in',
|
||||
},
|
||||
Blog: {
|
||||
label: 'Blog',
|
||||
path: '/blog',
|
||||
},
|
||||
Docs: {
|
||||
label: 'Documentation',
|
||||
path: '/docs',
|
||||
},
|
||||
Pricing: {
|
||||
label: 'Pricing',
|
||||
path: '/pricing',
|
||||
},
|
||||
FAQ: {
|
||||
label: 'FAQ',
|
||||
path: '/faq',
|
||||
},
|
||||
};
|
||||
|
||||
export function SiteNavigation() {
|
||||
const className = `hover:underline text-sm`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'hidden items-center lg:flex'}>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className={'space-x-2.5'}>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Blog.path}>
|
||||
{links.Blog.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Docs.path}>
|
||||
{links.Docs.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Pricing.path}>
|
||||
{links.Pricing.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.FAQ.path}>
|
||||
{links.FAQ.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center lg:hidden'}>
|
||||
<MobileDropdown />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={'Open Menu'}>
|
||||
<MenuIcon className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{Object.values(links).map((item) => {
|
||||
const className = 'flex w-full h-full items-center';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={item.path}>
|
||||
<Link className={className} href={item.path}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal file
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function SitePageHeader(props: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center space-y-2.5', props.className)}
|
||||
>
|
||||
<Heading level={1}>{props.title}</Heading>
|
||||
|
||||
<Heading level={2} className={'text-muted-foreground'}>
|
||||
<span className={'font-normal'}>{props.subtitle}</span>
|
||||
</Heading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
|
||||
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
|
||||
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
const getPageBySlug = cache((slug: string) => {
|
||||
return allDocumentationPages.find((post) => post.resolvedPath === slug);
|
||||
});
|
||||
|
||||
interface PageParams {
|
||||
params: {
|
||||
slug: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params }: PageParams) => {
|
||||
const page = getPageBySlug(params.slug.join('/'));
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, description } = page;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
};
|
||||
};
|
||||
|
||||
function DocumentationPage({ params }: PageParams) {
|
||||
const page = getPageBySlug(params.slug.join('/'));
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { nextPage, previousPage, children } =
|
||||
getDocumentationPageTree(page.resolvedPath) ?? {};
|
||||
|
||||
const description = page?.description ?? '';
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'relative flex grow flex-col space-y-4 px-8 py-8'}>
|
||||
<SitePageHeader
|
||||
title={page.title}
|
||||
subtitle={description}
|
||||
className={'items-start'}
|
||||
/>
|
||||
|
||||
<Mdx code={page.body.code} />
|
||||
|
||||
<If condition={children}>
|
||||
<DocsCards pages={children ?? []} />
|
||||
</If>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col justify-between space-y-4 md:flex-row md:space-x-8' +
|
||||
' md:space-y-0'
|
||||
}
|
||||
>
|
||||
<div className={'w-full'}>
|
||||
<If condition={previousPage}>
|
||||
{(page) => (
|
||||
<DocumentationPageLink
|
||||
page={page}
|
||||
before={<ChevronLeftIcon className={'w-4'} />}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'w-full'}>
|
||||
<If condition={nextPage}>
|
||||
{(page) => (
|
||||
<DocumentationPageLink
|
||||
page={page}
|
||||
after={<ChevronRightIcon className={'w-4'} />}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(DocumentationPage);
|
||||
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal file
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
export const DocsCard: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
label: string;
|
||||
subtitle?: string | null;
|
||||
link?: { url: string; label: string };
|
||||
}>
|
||||
> = ({ label, subtitle, children, link }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={`flex grow flex-col space-y-2.5 border bg-background p-6
|
||||
${link ? 'rounded-t-2xl border-b-0' : 'rounded-2xl'}`}
|
||||
>
|
||||
<h3 className="mt-0 text-lg font-semibold dark:text-white">{label}</h3>
|
||||
|
||||
{subtitle && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className="text-sm">{children}</div>}
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="rounded-b-2xl border bg-muted p-6 py-4 dark:bg-background">
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<Link
|
||||
className={'text-sm font-medium hover:underline'}
|
||||
href={`/docs/${link.url}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal file
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
import { DocsCard } from './docs-card';
|
||||
|
||||
export function DocsCards({ pages }: { pages: DocumentationPage[] }) {
|
||||
return (
|
||||
<div className={'grid grid-cols-1 gap-8 lg:grid-cols-2'}>
|
||||
{pages.map((item) => {
|
||||
return (
|
||||
<DocsCard
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
subtitle={item.description}
|
||||
link={{
|
||||
url: item.resolvedPath,
|
||||
label: item.cardCTA ?? 'Read more',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal file
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ChevronDownIcon, MenuIcon } from 'lucide-react';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree';
|
||||
|
||||
const DocsNavLink: React.FC<{
|
||||
label: string;
|
||||
url: string;
|
||||
level: number;
|
||||
activePath: string;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
}> = ({
|
||||
label,
|
||||
url,
|
||||
level,
|
||||
activePath,
|
||||
collapsible,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
}) => {
|
||||
const isCurrent = url == activePath;
|
||||
const isFirstLevel = level === 0;
|
||||
|
||||
return (
|
||||
<div className={getNavLinkClassName(isCurrent, isFirstLevel)}>
|
||||
<Link
|
||||
className="flex h-full max-w-full grow items-center space-x-2"
|
||||
href={`/docs/${url}`}
|
||||
>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
</Link>
|
||||
|
||||
{collapsible && (
|
||||
<button
|
||||
aria-label="Toggle children"
|
||||
onClick={toggleCollapsed}
|
||||
className="mr-2 shrink-0 px-2 py-1"
|
||||
>
|
||||
<span
|
||||
className={`block w-2.5 ${collapsed ? '-rotate-90 transform' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Node: React.FC<{
|
||||
node: ProcessedDocumentationPage;
|
||||
level: number;
|
||||
activePath: string;
|
||||
}> = ({ node, level, activePath }) => {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(node.collapsed ?? false);
|
||||
const toggleCollapsed = () => setCollapsed(!collapsed);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activePath == node.resolvedPath ||
|
||||
node.children.map((_) => _.resolvedPath).includes(activePath)
|
||||
) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
}, [activePath, node.children, node.resolvedPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocsNavLink
|
||||
label={node.label}
|
||||
url={node.resolvedPath}
|
||||
level={level}
|
||||
activePath={activePath}
|
||||
collapsible={node.collapsible}
|
||||
collapsed={collapsed}
|
||||
toggleCollapsed={toggleCollapsed}
|
||||
/>
|
||||
|
||||
{node.children.length > 0 && !collapsed && (
|
||||
<Tree tree={node.children} level={level + 1} activePath={activePath} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function Tree({
|
||||
tree,
|
||||
level,
|
||||
activePath,
|
||||
}: {
|
||||
tree: ProcessedDocumentationPage[];
|
||||
level: number;
|
||||
activePath: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('w-full space-y-2.5 pl-3', level > 0 ? 'border-l' : '')}>
|
||||
{tree.map((treeNode, index) => (
|
||||
<Node
|
||||
key={index}
|
||||
node={treeNode}
|
||||
level={level}
|
||||
activePath={activePath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsNavigation({
|
||||
tree,
|
||||
}: {
|
||||
tree: ProcessedDocumentationPage[];
|
||||
}) {
|
||||
const activePath = usePathname().replace('/docs/', '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
style={{
|
||||
height: `calc(100vh - 64px)`,
|
||||
}}
|
||||
className="sticky top-2 hidden w-80 shrink-0 border-r p-4 lg:flex"
|
||||
>
|
||||
<Tree tree={tree} level={0} activePath={activePath} />
|
||||
</aside>
|
||||
|
||||
<div className={'lg:hidden'}>
|
||||
<FloatingDocumentationNavigation tree={tree} activePath={activePath} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) {
|
||||
return cn(
|
||||
'group flex h-8 items-center justify-between space-x-2 whitespace-nowrap rounded-md px-3 text-sm leading-none transition-colors',
|
||||
{
|
||||
[`bg-muted`]: isCurrent,
|
||||
[`hover:bg-muted`]: !isCurrent,
|
||||
[`font-semibold`]: isFirstLevel,
|
||||
[`font-normal`]: !isFirstLevel && isCurrent,
|
||||
[`hover:text-foreground-muted`]: !isFirstLevel && !isCurrent,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingDocumentationNavigation({
|
||||
tree,
|
||||
activePath,
|
||||
}: React.PropsWithChildren<{
|
||||
tree: ProcessedDocumentationPage[];
|
||||
activePath: string;
|
||||
}>) {
|
||||
const body = useMemo(() => {
|
||||
return isBrowser() ? document.body : null;
|
||||
}, []);
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const enableScrolling = (element: HTMLElement) =>
|
||||
(element.style.overflowY = '');
|
||||
|
||||
const disableScrolling = (element: HTMLElement) =>
|
||||
(element.style.overflowY = 'hidden');
|
||||
|
||||
// enable/disable body scrolling when the docs are toggled
|
||||
useEffect(() => {
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
disableScrolling(body);
|
||||
} else {
|
||||
enableScrolling(body);
|
||||
}
|
||||
}, [isVisible, body]);
|
||||
|
||||
// hide docs when navigating to another page
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
}, [activePath]);
|
||||
|
||||
const onClick = () => {
|
||||
setIsVisible(!isVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={isVisible}>
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 z-10 h-screen w-full p-4' +
|
||||
' flex flex-col space-y-4 overflow-auto bg-white dark:bg-background'
|
||||
}
|
||||
>
|
||||
<Heading level={1}>Table of Contents</Heading>
|
||||
|
||||
<Tree tree={tree} level={0} activePath={activePath} />
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<Button
|
||||
className={'fixed bottom-5 right-5 z-10 h-16 w-16 rounded-full'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MenuIcon className={'h-8'} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function DocumentationPageLink({
|
||||
page,
|
||||
before,
|
||||
after,
|
||||
}: React.PropsWithChildren<{
|
||||
page: DocumentationPage;
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
`flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 ring-muted transition-all hover:ring-primary`,
|
||||
{
|
||||
'justify-start': before,
|
||||
'justify-end self-end': after,
|
||||
},
|
||||
)}
|
||||
href={`/docs/${page.resolvedPath}`}
|
||||
>
|
||||
<If condition={before}>{(node) => <>{node}</>}</If>
|
||||
|
||||
<span className={'flex flex-col space-y-1.5'}>
|
||||
<span
|
||||
className={
|
||||
'text-xs font-semibold uppercase dark:text-gray-400' +
|
||||
' text-gray-500'
|
||||
}
|
||||
>
|
||||
{before ? `Previous` : ``}
|
||||
{after ? `Next` : ``}
|
||||
</span>
|
||||
|
||||
<span>{page.title}</span>
|
||||
</span>
|
||||
|
||||
<If condition={after}>{(node) => <>{node}</>}</If>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/(marketing)/docs/layout.tsx
Normal file
21
apps/web/app/(marketing)/docs/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import DocsNavigation from './components/docs-navigation';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
|
||||
function DocsLayout({ children }: React.PropsWithChildren) {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex'}>
|
||||
<DocsNavigation tree={tree} />
|
||||
|
||||
<div className={'flex w-full flex-col items-center'}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocsLayout;
|
||||
30
apps/web/app/(marketing)/docs/page.tsx
Normal file
30
apps/web/app/(marketing)/docs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
import { DocsCards } from './components/docs-cards';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
|
||||
export const metadata = {
|
||||
title: `Documentation - ${appConfig.name}`,
|
||||
};
|
||||
|
||||
function DocsPage() {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
return (
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Documentation'}
|
||||
subtitle={'Get started with our guides and tutorials'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<DocsCards pages={tree ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(DocsPage);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
export interface ProcessedDocumentationPage extends DocumentationPage {
|
||||
collapsible: boolean;
|
||||
pathSegments: string[];
|
||||
nextPage: ProcessedDocumentationPage | DocumentationPage | null;
|
||||
previousPage: ProcessedDocumentationPage | DocumentationPage | null;
|
||||
children: DocsTree;
|
||||
}
|
||||
|
||||
export type DocsTree = ProcessedDocumentationPage[];
|
||||
|
||||
/**
|
||||
* Build a tree of documentation pages from a flat list of pages with path segments
|
||||
* @param docs
|
||||
* @param parentPathNames
|
||||
*/
|
||||
export const buildDocumentationTree = cache(
|
||||
(docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => {
|
||||
const level = parentPathNames.length;
|
||||
|
||||
const pages = docs
|
||||
.filter(
|
||||
(_) =>
|
||||
_.pathSegments.length === level + 1 &&
|
||||
_.pathSegments
|
||||
.map(({ pathName }: { pathName: string }) => pathName)
|
||||
.join('/')
|
||||
.startsWith(parentPathNames.join('/')),
|
||||
)
|
||||
.sort(
|
||||
(a, b) => a.pathSegments[level].order - b.pathSegments[level].order,
|
||||
);
|
||||
|
||||
return pages.map((doc, index) => {
|
||||
const children = buildDocumentationTree(
|
||||
docs,
|
||||
doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName),
|
||||
);
|
||||
|
||||
return {
|
||||
...doc,
|
||||
pathSegments: doc.pathSegments || ([] as string[]),
|
||||
collapsible: children.length > 0,
|
||||
nextPage: children[0] || pages[index + 1],
|
||||
previousPage: pages[index - 1],
|
||||
children,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import { buildDocumentationTree } from './build-documentation-tree';
|
||||
|
||||
/**
|
||||
* Retrieves a specific documentation page from the page tree by its path.
|
||||
*
|
||||
* @param {string} pagePath - The path of the documentation page to retrieve.
|
||||
* @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any.
|
||||
*/
|
||||
export const getDocumentationPageTree = cache((pagePath: string) => {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
type DocumentationPageWithChildren = DocumentationPage & {
|
||||
previousPage?: DocumentationPage | null;
|
||||
nextPage?: DocumentationPage | null;
|
||||
children?: DocumentationPage[];
|
||||
};
|
||||
|
||||
const findPageInTree = (
|
||||
pages: DocumentationPageWithChildren[],
|
||||
path: string,
|
||||
): DocumentationPageWithChildren | undefined => {
|
||||
for (const page of pages) {
|
||||
if (page.resolvedPath === path) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const hasChildren = page.children && page.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
const foundPage = findPageInTree(page.children ?? [], path);
|
||||
|
||||
if (foundPage) {
|
||||
return foundPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return findPageInTree(tree, pagePath);
|
||||
});
|
||||
123
apps/web/app/(marketing)/faq/page.tsx
Normal file
123
apps/web/app/(marketing)/faq/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'FAQ',
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: `Do you offer a free trial?`,
|
||||
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
|
||||
},
|
||||
{
|
||||
question: `Can I cancel my subscription?`,
|
||||
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Where can I find my invoices?`,
|
||||
answer: `You can find your invoices in your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `What payment methods do you accept?`,
|
||||
answer: `We accept all major credit cards and PayPal.`,
|
||||
},
|
||||
{
|
||||
question: `Can I upgrade or downgrade my plan?`,
|
||||
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Do you offer discounts for non-profits?`,
|
||||
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
|
||||
},
|
||||
];
|
||||
|
||||
const FAQPage = () => {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => {
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<script
|
||||
key={'ld:json'}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'FAQ'}
|
||||
subtitle={'Frequently Asked Questions'}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'm-auto flex w-full max-w-xl items-center justify-center'
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{faqItems.map((item, index) => {
|
||||
return <FaqItem key={index} item={item} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withI18n(FAQPage);
|
||||
|
||||
function FaqItem({
|
||||
item,
|
||||
}: React.PropsWithChildren<{
|
||||
item: {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
}>) {
|
||||
return (
|
||||
<details className={'group border-b px-2 py-4'}>
|
||||
<summary
|
||||
className={
|
||||
'flex items-center justify-between hover:cursor-pointer hover:underline'
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
'hover:underline-none cursor-pointer font-sans text-lg font-medium'
|
||||
}
|
||||
>
|
||||
{item.question}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<ChevronDownIcon
|
||||
className={'h-5 transition duration-300 group-open:-rotate-180'}
|
||||
/>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div
|
||||
className={'flex flex-col space-y-2 py-1 text-muted-foreground'}
|
||||
dangerouslySetInnerHTML={{ __html: item.answer }}
|
||||
/>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/(marketing)/layout.tsx
Normal file
18
apps/web/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SiteFooter } from './components/site-footer';
|
||||
import { SiteHeader } from './components/site-header';
|
||||
|
||||
function SiteLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
{props.children}
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(SiteLayout);
|
||||
3
apps/web/app/(marketing)/loading.tsx
Normal file
3
apps/web/app/(marketing)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
285
apps/web/app/(marketing)/page.tsx
Normal file
285
apps/web/app/(marketing)/page.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-16'}>
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'my-12 flex flex-col items-center md:flex-row lg:my-16' +
|
||||
' mx-auto flex-1 justify-center animate-in fade-in ' +
|
||||
' duration-1000 slide-in-from-top-12'
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-1 flex-col items-center space-y-8'}>
|
||||
<Pill>
|
||||
<span>The leading SaaS Starter Kit for ambitious developers</span>
|
||||
</Pill>
|
||||
|
||||
<HeroTitle>
|
||||
<span>The SaaS Solution for</span>
|
||||
<span>developers and founders</span>
|
||||
</HeroTitle>
|
||||
|
||||
<div>
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>Here you can write a short description of your SaaS</span>
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>
|
||||
This subheading is usually laid out on multiple lines
|
||||
</span>
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>Impress your customers, straight to the point.</span>
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<MainCallToActionButton />
|
||||
|
||||
<span className={'text-xs text-muted-foreground'}>
|
||||
Free plan. No credit card required.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mx-auto flex max-w-5xl justify-center py-12 animate-in fade-in ' +
|
||||
' delay-300 duration-1000 slide-in-from-top-16 fill-mode-both'
|
||||
}
|
||||
>
|
||||
<Image
|
||||
priority
|
||||
className={
|
||||
'rounded-2xl' +
|
||||
' shadow-primary/40 animate-in fade-in' +
|
||||
' delay-300 duration-1000 ease-out zoom-in-50 fill-mode-both'
|
||||
}
|
||||
width={2688}
|
||||
height={1824}
|
||||
src={`/assets/images/dashboard-dark.webp`}
|
||||
alt={`App Image`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-24 py-16'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex max-w-3xl flex-col items-center space-y-8 text-center'
|
||||
}
|
||||
>
|
||||
<Pill>
|
||||
<span>A modern, scalable, and secure SaaS Starter Kit</span>
|
||||
</Pill>
|
||||
|
||||
<div className={'flex flex-col space-y-2.5'}>
|
||||
<Heading level={2}>The best tool in the space</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Unbeatable Features and Benefits for Your SaaS Business
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FeatureShowcaseContainer>
|
||||
<LeftFeatureContainer>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={2}>Authentication</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Secure and Easy-to-Use Authentication for Your SaaS Website
|
||||
and API
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Our authentication system is built on top of the
|
||||
industry-leading PaaS such as Supabase and Firebase. It is
|
||||
secure, easy-to-use, and fully customizable. It supports
|
||||
email/password, social logins, and more.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant={'outline'}>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>Get Started</span>
|
||||
<ChevronRightIcon className={'h-3'} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</LeftFeatureContainer>
|
||||
|
||||
<RightFeatureContainer>
|
||||
<Image
|
||||
className="rounded-2xl"
|
||||
src={'/assets/images/sign-in.webp'}
|
||||
width={'626'}
|
||||
height={'683'}
|
||||
alt={'Sign In'}
|
||||
/>
|
||||
</RightFeatureContainer>
|
||||
</FeatureShowcaseContainer>
|
||||
|
||||
<FeatureShowcaseContainer>
|
||||
<LeftFeatureContainer>
|
||||
<Image
|
||||
className="rounded-2xl"
|
||||
src={'/assets/images/dashboard.webp'}
|
||||
width={'887'}
|
||||
height={'743'}
|
||||
alt={'Dashboard'}
|
||||
/>
|
||||
</LeftFeatureContainer>
|
||||
|
||||
<RightFeatureContainer>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={1}>Dashboard</Heading>
|
||||
|
||||
<Heading level={2} className={'text-muted-foreground'}>
|
||||
A fantastic dashboard to manage your SaaS business
|
||||
</Heading>
|
||||
|
||||
<div>
|
||||
Our dashboard offers an overview of your SaaS business. It
|
||||
shows at a glance all you need to know about your business. It
|
||||
is fully customizable and extendable.
|
||||
</div>
|
||||
</div>
|
||||
</RightFeatureContainer>
|
||||
</FeatureShowcaseContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-16 py-16'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center space-y-8 text-center'}>
|
||||
<Pill>
|
||||
Get started for free. No credit card required. Cancel anytime.
|
||||
</Pill>
|
||||
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Heading level={2}>
|
||||
Ready to take your SaaS business to the next level?
|
||||
</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Get started on our free plan and upgrade when you are ready.
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'w-full'}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(Home);
|
||||
|
||||
function HeroTitle({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<h1
|
||||
className={
|
||||
'text-center text-4xl md:text-5xl' +
|
||||
' font-heading flex flex-col font-bold xl:text-7xl'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<h2
|
||||
className={
|
||||
'inline-flex w-auto items-center space-x-2' +
|
||||
' rounded-full bg-gradient-to-br dark:from-gray-200 dark:via-gray-400' +
|
||||
' bg-clip-text px-4 py-2 text-center text-sm dark:to-gray-700' +
|
||||
' border font-normal text-muted-foreground shadow-sm dark:text-transparent'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureShowcaseContainer(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-between lg:flex-row' +
|
||||
' lg:space-x-24'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftFeatureContainer(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-8 lg:w-6/12'}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RightFeatureContainer(props: React.PropsWithChildren) {
|
||||
return <div className={'flex w-full lg:w-6/12'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
function MainCallToActionButton() {
|
||||
return (
|
||||
<Button className={'rounded-full'}>
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>Get Started</span>
|
||||
|
||||
<ChevronRightIcon
|
||||
className={
|
||||
'h-4 animate-in fade-in slide-in-from-left-8' +
|
||||
' delay-1000 duration-1000 zoom-in fill-mode-both'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/(marketing)/pricing/page.tsx
Normal file
28
apps/web/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PricingTable } from '@kit/billing/components/pricing-table';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Pricing',
|
||||
};
|
||||
|
||||
function PricingPage() {
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Pricing'}
|
||||
subtitle={'Our pricing is designed to scale with your business.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PricingTable paths={pathsConfig.auth} config={billingConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PricingPage);
|
||||
21
apps/web/app/admin/layout.tsx
Normal file
21
apps/web/app/admin/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { Page } from '@/components/app/Page';
|
||||
|
||||
import AdminSidebar from '../../packages/admin/components/AdminSidebar';
|
||||
import isUserSuperAdmin from './utils/is-user-super-admin';
|
||||
|
||||
async function AdminLayout({ children }: React.PropsWithChildren) {
|
||||
const isAdmin = await isUserSuperAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const csrfToken = headers().get('X-CSRF-Token');
|
||||
|
||||
return <Page sidebar={<AdminSidebar />}>{children}</Page>;
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
21
apps/web/app/admin/lib/actions-utils.ts
Normal file
21
apps/web/app/admin/lib/actions-utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
export function withAdminSession<Args extends unknown[], Response>(
|
||||
fn: (...params: Args) => Response,
|
||||
) {
|
||||
return async (...params: Args) => {
|
||||
const isAdmin = await isUserSuperAdmin({
|
||||
client: getSupabaseServerActionClient(),
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return fn(...params);
|
||||
};
|
||||
}
|
||||
3
apps/web/app/admin/loading.tsx
Normal file
3
apps/web/app/admin/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -0,0 +1,29 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const deleteOrganizationAction = withAdminSession(
|
||||
async ({ id }: { id: number; csrfToken: string }) => {
|
||||
const client = getClient();
|
||||
|
||||
Logger.info({ id }, `Admin requested to delete Organization`);
|
||||
|
||||
await deleteOrganization(client, {
|
||||
organizationId: id,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/organizations', 'page');
|
||||
|
||||
Logger.info({ id }, `Organization account deleted`);
|
||||
|
||||
redirect('/admin/organizations');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
import { deleteOrganizationAction } from '../actions.server';
|
||||
|
||||
function DeleteOrganizationModal({
|
||||
organization,
|
||||
}: React.PropsWithChildren<{
|
||||
organization: Organization;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await deleteOrganizationAction({
|
||||
id: organization.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deleting Organization</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to delete the organization{' '}
|
||||
<b>{organization.name}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Delete this organization will potentially delete the data
|
||||
associated with it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>This action is not reversible</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Confirm by typing <b>DELETE</b>
|
||||
<Input required type={'text'} pattern={'DELETE'} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, delete organization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteOrganizationModal;
|
||||
@@ -0,0 +1,25 @@
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function DeleteOrganizationModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = await getOrganizationByUid(client, params.uid);
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`Organization not found`);
|
||||
}
|
||||
|
||||
return <DeleteOrganizationModal organization={data} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(DeleteOrganizationModalPage);
|
||||
3
apps/web/app/admin/organizations/@modal/default.tsx
Normal file
3
apps/web/app/admin/organizations/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import type UserData from '@kit/session/types/user-data';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
import type Membership from '@/lib/organizations/types/membership';
|
||||
|
||||
import { DataTable } from '@/components/app/DataTable';
|
||||
|
||||
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
|
||||
|
||||
type Data = {
|
||||
id: Membership['id'];
|
||||
role: Membership['role'];
|
||||
user: {
|
||||
id: UserData['id'];
|
||||
displayName: UserData['displayName'];
|
||||
};
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Data>[] = [
|
||||
{
|
||||
header: 'Membership ID',
|
||||
id: 'id',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'User ID',
|
||||
id: 'user-id',
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.user.id;
|
||||
|
||||
return (
|
||||
<Link className={'hover:underline'} href={`/admin/users/${userId}`}>
|
||||
{userId}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
id: 'name',
|
||||
accessorKey: 'user.displayName',
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={'inline-flex'}>
|
||||
<RoleBadge role={row.original.role} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const membership = row.original;
|
||||
const userId = membership.user.id;
|
||||
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={'icon'}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${userId}`}>View User</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${userId}/impersonate`}>
|
||||
Impersonate User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function OrganizationsMembersTable({
|
||||
memberships,
|
||||
page,
|
||||
perPage,
|
||||
pageCount,
|
||||
}: React.PropsWithChildren<{
|
||||
memberships: Data[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
pageCount: number;
|
||||
}>) {
|
||||
const data = memberships.filter((membership) => {
|
||||
return membership.user;
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
tableProps={{
|
||||
'data-test': 'admin-organization-members-table',
|
||||
}}
|
||||
onPaginationChange={({ pageIndex }) => {
|
||||
const { pathname } = new URL(path, window.location.origin);
|
||||
const page = pageIndex + 1;
|
||||
|
||||
router.push(pathname + '?page=' + page);
|
||||
}}
|
||||
pageCount={pageCount}
|
||||
pageIndex={page - 1}
|
||||
pageSize={perPage}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsMembersTable;
|
||||
82
apps/web/app/admin/organizations/[uid]/members/page.tsx
Normal file
82
apps/web/app/admin/organizations/[uid]/members/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import AdminHeader from '@packages/admin/components/AdminHeader';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
|
||||
import { getMembershipsByOrganizationUid } from '../../queries';
|
||||
import OrganizationsMembersTable from './components/OrganizationsMembersTable';
|
||||
|
||||
interface AdminMembersPageParams {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
|
||||
searchParams: {
|
||||
page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Members | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
function AdminMembersPage(params: AdminMembersPageParams) {
|
||||
const adminClient = getSupabaseServerComponentClient({ admin: true });
|
||||
const uid = params.params.uid;
|
||||
const perPage = 20;
|
||||
const page = getPageFromQueryParams(params.searchParams.page);
|
||||
|
||||
const { data: memberships, count } = use(
|
||||
getMembershipsByOrganizationUid(adminClient, { uid, page, perPage }),
|
||||
);
|
||||
|
||||
const pageCount = count ? Math.ceil(count / perPage) : 0;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage Members</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Breadcrumbs />
|
||||
|
||||
<OrganizationsMembersTable
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
pageCount={pageCount}
|
||||
memberships={memberships}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminMembersPage;
|
||||
|
||||
function Breadcrumbs() {
|
||||
return (
|
||||
<div className={'flex items-center space-x-2 p-2 text-xs'}>
|
||||
<div className={'flex items-center space-x-1.5'}>
|
||||
<Link href={'/admin'}>Admin</Link>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
|
||||
<Link href={'/admin/organizations'}>Organizations</Link>
|
||||
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
|
||||
<span>Members</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisIcon } from 'lucide-react';
|
||||
import { getI18n } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
import pricingConfig from '@/config/pricing.config';
|
||||
|
||||
import { DataTable } from '@/components/app/DataTable';
|
||||
|
||||
import SubscriptionStatusBadge from '../../../(app)/[account]/components/organizations/SubscriptionStatusBadge';
|
||||
import type { getOrganizations } from '../queries';
|
||||
|
||||
type Response = Awaited<ReturnType<typeof getOrganizations>>;
|
||||
type Organizations = Response['organizations'];
|
||||
|
||||
const columns: ColumnDef<Organizations[0]>[] = [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
id: 'id',
|
||||
size: 10,
|
||||
},
|
||||
{
|
||||
header: 'UUID',
|
||||
accessorKey: 'uuid',
|
||||
id: 'uuid',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Subscription',
|
||||
id: 'subscription',
|
||||
cell: ({ row }) => {
|
||||
const priceId = row.original?.subscription?.data?.priceId;
|
||||
|
||||
const plan = pricingConfig.products.find((product) => {
|
||||
return product.plans.some((plan) => plan.stripePriceId === priceId);
|
||||
});
|
||||
|
||||
if (plan) {
|
||||
const price = plan.plans.find((plan) => plan.stripePriceId === priceId);
|
||||
|
||||
if (!price) {
|
||||
return 'Unknown Price';
|
||||
}
|
||||
|
||||
return `${plan.name} - ${price.name}`;
|
||||
}
|
||||
|
||||
return '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Subscription Status',
|
||||
id: 'subscription-status',
|
||||
cell: ({ row }) => {
|
||||
const subscription = row.original?.subscription?.data;
|
||||
|
||||
if (!subscription) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return <SubscriptionStatusBadge subscription={subscription} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Subscription Period',
|
||||
id: 'subscription-period',
|
||||
cell: ({ row }) => {
|
||||
const subscription = row.original?.subscription?.data;
|
||||
const i18n = getI18n();
|
||||
const language = i18n.language ?? 'en';
|
||||
|
||||
if (!subscription) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const canceled = subscription.cancelAtPeriodEnd;
|
||||
const date = subscription.periodEndsAt;
|
||||
const formattedDate = new Date(date).toLocaleDateString(language);
|
||||
|
||||
return canceled ? (
|
||||
<span className={'text-orange-500'}>Stops on {formattedDate}</span>
|
||||
) : (
|
||||
<span className={'text-green-500'}>Renews on {formattedDate}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Members',
|
||||
id: 'members',
|
||||
cell: ({ row }) => {
|
||||
const memberships = row.original.memberships.filter((item) => !item.code);
|
||||
const invites = row.original.memberships.length - memberships.length;
|
||||
const uid = row.original.uuid;
|
||||
const length = memberships.length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
data-test={'organization-members-link'}
|
||||
href={`organizations/${uid}/members`}
|
||||
className={'cursor-pointer hover:underline'}
|
||||
>
|
||||
{length} member{length === 1 ? '' : 's'}{' '}
|
||||
{invites ? `(${invites} invites)` : ''}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const organization = row.original;
|
||||
const uid = organization.uuid;
|
||||
|
||||
return (
|
||||
<div className={'flex justify-end'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={'icon'}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<EllipsisIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(uid)}
|
||||
>
|
||||
Copy UUID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/organizations/${uid}/members`}>
|
||||
View Members
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'text-red-500'}
|
||||
href={`/admin/organizations/${uid}/delete`}
|
||||
>
|
||||
Delete
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function OrganizationsTable({
|
||||
organizations,
|
||||
pageCount,
|
||||
perPage,
|
||||
page,
|
||||
}: React.PropsWithChildren<{
|
||||
organizations: Organizations;
|
||||
pageCount: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
}>) {
|
||||
return (
|
||||
<DataTable
|
||||
tableProps={{
|
||||
'data-test': 'admin-organizations-table',
|
||||
}}
|
||||
pageSize={perPage}
|
||||
pageIndex={page - 1}
|
||||
pageCount={pageCount}
|
||||
columns={columns}
|
||||
data={organizations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsTable;
|
||||
3
apps/web/app/admin/organizations/default.tsx
Normal file
3
apps/web/app/admin/organizations/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
21
apps/web/app/admin/organizations/error.tsx
Normal file
21
apps/web/app/admin/organizations/error.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
function OrganizationsAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Could not load organizations</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an error loading the organizations. Please check your
|
||||
console errors.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsAdminPageError;
|
||||
14
apps/web/app/admin/organizations/layout.tsx
Normal file
14
apps/web/app/admin/organizations/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
function OrganizationsLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
{props.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsLayout;
|
||||
69
apps/web/app/admin/organizations/page.tsx
Normal file
69
apps/web/app/admin/organizations/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import AdminGuard from '@/packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '@/packages/admin/components/AdminHeader';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { Input } from '@kit/ui/input';
|
||||
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import OrganizationsTable from './components/OrganizationsTable';
|
||||
import { getOrganizations } from './queries';
|
||||
|
||||
interface OrganizationsAdminPageProps {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Organizations | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
async function OrganizationsAdminPage({
|
||||
searchParams,
|
||||
}: OrganizationsAdminPageProps) {
|
||||
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const perPage = 10;
|
||||
const search = searchParams.search || '';
|
||||
|
||||
const { organizations, count } = await getOrganizations(
|
||||
client,
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
);
|
||||
|
||||
const pageCount = count ? Math.ceil(count / perPage) : 0;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage Organizations</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<form method={'GET'}>
|
||||
<Input
|
||||
name={'search'}
|
||||
defaultValue={search}
|
||||
placeholder={'Search Organization...'}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<OrganizationsTable
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(OrganizationsAdminPage);
|
||||
132
apps/web/app/admin/organizations/queries.ts
Normal file
132
apps/web/app/admin/organizations/queries.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@/database.types';
|
||||
|
||||
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
|
||||
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
|
||||
type Client = SupabaseClient<Database>;
|
||||
|
||||
export async function getOrganizations(
|
||||
client: Client,
|
||||
search: string,
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
) {
|
||||
const startOffset = (page - 1) * perPage;
|
||||
const endOffset = startOffset - 1 + perPage;
|
||||
|
||||
let query = client.from(ORGANIZATIONS_TABLE).select<
|
||||
string,
|
||||
UserOrganizationData['organization'] & {
|
||||
memberships: {
|
||||
userId: string;
|
||||
role: MembershipRole;
|
||||
code: string;
|
||||
}[];
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
uuid,
|
||||
name,
|
||||
logoURL: logo_url,
|
||||
memberships (
|
||||
userId: user_id,
|
||||
role,
|
||||
code
|
||||
),
|
||||
subscription: organizations_subscriptions (
|
||||
customerId: customer_id,
|
||||
data: subscription_id (
|
||||
id,
|
||||
status,
|
||||
currency,
|
||||
interval,
|
||||
cancelAtPeriodEnd: cancel_at_period_end,
|
||||
intervalCount: interval_count,
|
||||
priceId: price_id,
|
||||
createdAt: created_at,
|
||||
periodStartsAt: period_starts_at,
|
||||
periodEndsAt: period_ends_at,
|
||||
trialStartsAt: trial_starts_at,
|
||||
trialEndsAt: trial_ends_at
|
||||
)
|
||||
)`,
|
||||
{
|
||||
count: 'exact',
|
||||
},
|
||||
);
|
||||
|
||||
if (search) {
|
||||
query = query.ilike('name', `%${search}%`);
|
||||
}
|
||||
|
||||
const {
|
||||
data: organizations,
|
||||
count,
|
||||
error,
|
||||
} = await query.range(startOffset, endOffset);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
organizations,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMembershipsByOrganizationUid(
|
||||
client: Client,
|
||||
params: {
|
||||
uid: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
},
|
||||
) {
|
||||
const startOffset = (params.page - 1) * params.perPage;
|
||||
const endOffset = startOffset + params.perPage;
|
||||
|
||||
const { data, error, count } = await client
|
||||
.from(MEMBERSHIPS_TABLE)
|
||||
.select<
|
||||
string,
|
||||
{
|
||||
id: number;
|
||||
role: MembershipRole;
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
photoURL: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
role,
|
||||
user: user_id (
|
||||
id,
|
||||
displayName: display_name,
|
||||
photoURL: photo_url
|
||||
),
|
||||
organization: organization_id !inner (
|
||||
id,
|
||||
uuid
|
||||
)`,
|
||||
{
|
||||
count: 'exact',
|
||||
},
|
||||
)
|
||||
.eq('organization.uuid', params.uid)
|
||||
.is('code', null)
|
||||
.range(startOffset, endOffset);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { data, count };
|
||||
}
|
||||
68
apps/web/app/admin/page.tsx
Normal file
68
apps/web/app/admin/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
|
||||
import AdminGuard from '../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../packages/admin/components/AdminHeader';
|
||||
|
||||
export const metadata = {
|
||||
title: `Admin | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
async function AdminPage() {
|
||||
const data = await loadData();
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Admin</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<AdminDashboard data={data} />
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminPage);
|
||||
|
||||
async function loadData() {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
const { count: usersCount } = await client.from('users').select('*', {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
});
|
||||
|
||||
const { count: organizationsCount } = await client
|
||||
.from('organizations')
|
||||
.select('*', {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
});
|
||||
|
||||
const { count: activeSubscriptions } = await client
|
||||
.from('subscriptions')
|
||||
.select(`*`, {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
})
|
||||
.eq('status', 'active');
|
||||
|
||||
const { count: trialSubscriptions } = await client
|
||||
.from('subscriptions')
|
||||
.select(`*`, {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
})
|
||||
.eq('status', 'trialing');
|
||||
|
||||
return {
|
||||
usersCount: usersCount || 0,
|
||||
organizationsCount: organizationsCount || 0,
|
||||
activeSubscriptions: activeSubscriptions || 0,
|
||||
trialSubscriptions: trialSubscriptions || 0,
|
||||
};
|
||||
}
|
||||
127
apps/web/app/admin/users/@modal/[uid]/actions.server.ts
Normal file
127
apps/web/app/admin/users/@modal/[uid]/actions.server.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const banUser = withAdminSession(async ({ userId }) => {
|
||||
await setBanDuration(userId, `876600h`);
|
||||
});
|
||||
|
||||
export const reactivateUser = withAdminSession(async ({ userId }) => {
|
||||
await setBanDuration(userId, `none`);
|
||||
});
|
||||
|
||||
export const impersonateUser = withAdminSession(async ({ userId }) => {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const client = getClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await client.auth.admin.getUserById(userId);
|
||||
|
||||
if (error || !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot impersonate`);
|
||||
}
|
||||
|
||||
const { error: linkError, data } = await getClient().auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email,
|
||||
options: {
|
||||
redirectTo: `/`,
|
||||
},
|
||||
});
|
||||
|
||||
if (linkError || !data) {
|
||||
throw new Error(`Error generating magic link`);
|
||||
}
|
||||
|
||||
const response = await fetch(data.properties?.action_link, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
|
||||
if (!location) {
|
||||
throw new Error(`Error generating magic link. Location header not found`);
|
||||
}
|
||||
|
||||
const hash = new URL(location).hash.substring(1);
|
||||
const query = new URLSearchParams(hash);
|
||||
const accessToken = query.get('access_token');
|
||||
const refreshToken = query.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error(
|
||||
`Error generating magic link. Tokens not found in URL hash.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteUserAction = withAdminSession(
|
||||
async ({ userId }: { userId: string; csrfToken: string }) => {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
Logger.info({ userId }, `Admin requested to delete user account`);
|
||||
|
||||
// we don't want to send an email to the user
|
||||
const sendEmail = false;
|
||||
|
||||
await deleteUser({
|
||||
client: getClient(),
|
||||
userId,
|
||||
sendEmail,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/users', 'page');
|
||||
|
||||
Logger.info({ userId }, `User account deleted`);
|
||||
|
||||
redirect('/admin/users');
|
||||
},
|
||||
);
|
||||
|
||||
async function setBanDuration(userId: string, banDuration: string) {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
await getClient().auth.admin.updateUserById(userId, {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
}
|
||||
|
||||
async function assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
|
||||
const { data: user } = await getSupabaseServerActionClient().auth.getUser();
|
||||
const currentUserId = user.user?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on your own account as a Super Admin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
32
apps/web/app/admin/users/@modal/[uid]/ban/page.tsx
Normal file
32
apps/web/app/admin/users/@modal/[uid]/ban/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import BanUserModal from '../components/BanUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function BanUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
const user = data.user;
|
||||
const isBanned = 'banned_until' in user && user.banned_until !== 'none';
|
||||
|
||||
if (isBanned) {
|
||||
throw new Error(`The user is already banned`);
|
||||
}
|
||||
|
||||
return <BanUserModal user={user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(BanUserModalPage);
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
|
||||
import { banUser } from '../actions.server';
|
||||
|
||||
function BanUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
await banUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban User</DialogTitle>
|
||||
|
||||
<ErrorBoundary fallback={<BanErrorAlert />}>
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to ban <b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can unban them later, but they will not be able to log
|
||||
in or use their account until you do.
|
||||
</p>
|
||||
|
||||
<Label>
|
||||
Type <b>BAN</b> to confirm
|
||||
<Input type="text" required pattern={'BAN'} />
|
||||
</Label>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ErrorBoundary>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, ban user
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default BanUserModal;
|
||||
|
||||
function BanErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>There was an error banning this user.</AlertTitle>
|
||||
|
||||
<AlertDescription>Check the logs for more information.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { deleteUserAction } from '../actions.server';
|
||||
|
||||
function DeleteUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await deleteUserAction({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deleting User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to delete the user <b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Delete this user will also delete the organizations they are a
|
||||
Owner of, and potentially the data associated with those
|
||||
organizations.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>This action is not reversible</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Confirm by typing <b>DELETE</b>
|
||||
<Input required type={'text'} pattern={'DELETE'} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, delete user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteUserModal;
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import useSupabase from '@kit/hooks/use-supabase';
|
||||
|
||||
import Spinner from '@/components/app/Spinner';
|
||||
|
||||
function ImpersonateUserAuthSetter({
|
||||
tokens,
|
||||
}: React.PropsWithChildren<{
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
}>) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function setAuth() {
|
||||
await supabase.auth.setSession({
|
||||
refresh_token: tokens.refreshToken,
|
||||
access_token: tokens.accessToken,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
void setAuth();
|
||||
}, [router, tokens, supabase.auth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-screen w-screen flex-1 flex-col items-center justify-center'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<p>Setting up your session...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserAuthSetter;
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import LoadingOverlay from '@/components/app/LoadingOverlay';
|
||||
|
||||
import { impersonateUser } from '../actions.server';
|
||||
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';
|
||||
|
||||
function ImpersonateUserConfirmationModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const [tokens, setTokens] = useState<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>();
|
||||
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await impersonateUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
setTokens(response);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Impersonate User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<If condition={tokens}>
|
||||
{(tokens) => {
|
||||
return (
|
||||
<>
|
||||
<ImpersonateUserAuthSetter tokens={tokens} />
|
||||
|
||||
<LoadingOverlay>Setting up your session...</LoadingOverlay>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Impersonation Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Sorry, something went wrong. Please check the logs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!error && !tokens}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to impersonate the account belonging to{' '}
|
||||
<b>{displayText}</b> with ID <b>{user.id}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You will be able to log in as them, see and do everything they
|
||||
can. To return to your own account, simply log out.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Like Uncle Ben said, with great power comes great
|
||||
responsibility. Use this power wisely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
disabled={pending}
|
||||
variant={'destructive'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Yes, let's do it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserConfirmationModal;
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import { reactivateUser } from '../actions.server';
|
||||
|
||||
function ReactivateUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await reactivateUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reactivate User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to reactivate the account belonging to{' '}
|
||||
<b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} onClick={onConfirm}>
|
||||
Yes, reactivate user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReactivateUserModal;
|
||||
25
apps/web/app/admin/users/@modal/[uid]/delete/page.tsx
Normal file
25
apps/web/app/admin/users/@modal/[uid]/delete/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import DeleteUserModal from '../components/DeleteUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function DeleteUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
return <DeleteUserModal user={data.user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(DeleteUserModalPage);
|
||||
25
apps/web/app/admin/users/@modal/[uid]/impersonate/page.tsx
Normal file
25
apps/web/app/admin/users/@modal/[uid]/impersonate/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import ImpersonateUserConfirmationModal from '../components/ImpersonateUserConfirmationModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ImpersonateUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
return <ImpersonateUserConfirmationModal user={data.user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(ImpersonateUserModalPage);
|
||||
34
apps/web/app/admin/users/@modal/[uid]/reactivate/page.tsx
Normal file
34
apps/web/app/admin/users/@modal/[uid]/reactivate/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import ReactivateUserModal from '../components/ReactivateUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ReactivateUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
const user = data.user;
|
||||
const isActive = !('banned_until' in user) || user.banned_until === 'none';
|
||||
|
||||
if (isActive) {
|
||||
redirect(`/admin/users`);
|
||||
}
|
||||
|
||||
return <ReactivateUserModal user={user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(ReactivateUserModalPage);
|
||||
3
apps/web/app/admin/users/@modal/default.tsx
Normal file
3
apps/web/app/admin/users/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
|
||||
function UserActionsDropdown({
|
||||
uid,
|
||||
isBanned,
|
||||
}: React.PropsWithChildren<{
|
||||
uid: string;
|
||||
isBanned: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'}>
|
||||
<span className={'flex items-center space-x-2.5'}>
|
||||
<span>Manage User</span>
|
||||
|
||||
<EllipsisVerticalIcon className={'w-4'} />
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${uid}/impersonate`}>Impersonate</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={!isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'text-orange-500'}
|
||||
href={`/admin/users/${uid}/ban`}
|
||||
>
|
||||
Ban
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${uid}/reactivate`}>Reactivate</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className={'text-red-500'} href={`/admin/users/${uid}/delete`}>
|
||||
Delete
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserActionsDropdown;
|
||||
241
apps/web/app/admin/users/[uid]/page.tsx
Normal file
241
apps/web/app/admin/users/[uid]/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
import configuration from '@/config/app.config';
|
||||
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
|
||||
import AdminGuard from '../../../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../../../packages/admin/components/AdminHeader';
|
||||
import UserActionsDropdown from './components/UserActionsDropdown';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Manage User | ${configuration.name}`,
|
||||
};
|
||||
|
||||
async function AdminUserPage({ params }: Params) {
|
||||
const uid = params.uid;
|
||||
|
||||
const data = await loadData(uid);
|
||||
const { auth, user } = data;
|
||||
const displayName = user?.displayName;
|
||||
const authUser = auth?.user;
|
||||
const email = authUser?.email;
|
||||
const phone = authUser?.phone;
|
||||
const organizations = data.organizations ?? [];
|
||||
|
||||
const isBanned = Boolean(
|
||||
authUser && 'banned_until' in authUser && authUser.banned_until !== 'none',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage User</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div className={'flex justify-between'}>
|
||||
<Breadcrumbs displayName={displayName ?? email ?? ''} />
|
||||
|
||||
<div>
|
||||
<UserActionsDropdown uid={uid} isBanned={isBanned} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center space-x-2'}>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
</div>
|
||||
|
||||
<div className={'inline-flex'}>
|
||||
{isBanned ? (
|
||||
<Badge variant={'destructive'}>Banned</Badge>
|
||||
) : (
|
||||
<Badge variant={'success'}>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>
|
||||
Display name
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={displayName ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label>
|
||||
Email
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={email ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label>
|
||||
Phone number
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={phone ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Organization ID</TableHead>
|
||||
<TableHead>UUID</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{organizations.map((membership) => {
|
||||
const organization = membership.organization;
|
||||
const href = `/admin/organizations/${organization.uuid}/members`;
|
||||
|
||||
return (
|
||||
<TableRow key={membership.id}>
|
||||
<TableCell>{organization.id}</TableCell>
|
||||
<TableCell>{organization.uuid}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Link className={'hover:underline'} href={href}>
|
||||
{organization.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className={'inline-flex'}>
|
||||
<RoleBadge role={membership.role} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminUserPage);
|
||||
|
||||
async function loadData(uid: string) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const authUser = client.auth.admin.getUserById(uid);
|
||||
|
||||
const userData = client
|
||||
.from('users')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
displayName: display_name,
|
||||
photoURL: photo_url,
|
||||
onboarded
|
||||
`,
|
||||
)
|
||||
.eq('id', uid)
|
||||
.single();
|
||||
|
||||
const organizationsQuery = client
|
||||
.from('memberships')
|
||||
.select<
|
||||
string,
|
||||
{
|
||||
id: number;
|
||||
role: MembershipRole;
|
||||
organization: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
role,
|
||||
organization: organization_id !inner (
|
||||
id,
|
||||
uuid,
|
||||
name
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('user_id', uid);
|
||||
|
||||
const [auth, user, organizations] = await Promise.all([
|
||||
authUser,
|
||||
userData,
|
||||
organizationsQuery,
|
||||
]);
|
||||
|
||||
return {
|
||||
auth: auth.data,
|
||||
user: user.data,
|
||||
organizations: organizations.data,
|
||||
};
|
||||
}
|
||||
|
||||
function Breadcrumbs(
|
||||
props: React.PropsWithChildren<{
|
||||
displayName: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex items-center space-x-1 p-2 text-xs'}>
|
||||
<Link href={'/admin'}>Admin</Link>
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
<Link href={'/admin/users'}>Users</Link>
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
<span>{props.displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
apps/web/app/admin/users/components/UsersTable.tsx
Normal file
239
apps/web/app/admin/users/components/UsersTable.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisIcon } from 'lucide-react';
|
||||
import { getI18n } from 'react-i18next';
|
||||
|
||||
import type UserData from '@kit/session/types/user-data';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kit/ui/tooltip';
|
||||
|
||||
import { DataTable } from '@/components/app/DataTable';
|
||||
import If from '@/components/app/If';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string | undefined;
|
||||
phone: string | undefined;
|
||||
createdAt: string;
|
||||
updatedAt: string | undefined;
|
||||
lastSignInAt: string | undefined;
|
||||
banDuration: string | undefined;
|
||||
data: UserData;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
header: '',
|
||||
id: 'avatar',
|
||||
size: 10,
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const data = user.data;
|
||||
const displayName = data?.displayName;
|
||||
const photoUrl = data?.photoUrl;
|
||||
const displayText = displayName ?? user.email ?? user.phone ?? '';
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Avatar>
|
||||
{photoUrl ? <AvatarImage src={photoUrl} /> : null}
|
||||
<AvatarFallback>{displayText[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>{displayText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'ID',
|
||||
id: 'id',
|
||||
size: 30,
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id;
|
||||
|
||||
return (
|
||||
<Link className={'hover:underline'} href={`/admin/users/${id}`}>
|
||||
{id}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
id: 'email',
|
||||
cell: ({ row }) => {
|
||||
const email = row.original.email;
|
||||
|
||||
return (
|
||||
<span title={email} className={'block max-w-full truncate'}>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 50,
|
||||
id: 'displayName',
|
||||
cell: ({ row }) => {
|
||||
return row.original.data?.displayName ?? '';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Created at',
|
||||
id: 'createdAt',
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
const i18n = getI18n();
|
||||
const language = i18n.language ?? 'en';
|
||||
const createdAtLabel = date.toLocaleDateString(language);
|
||||
|
||||
return <span>{createdAtLabel}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last sign in',
|
||||
id: 'lastSignInAt',
|
||||
cell: ({ row }) => {
|
||||
const lastSignInAt = row.original.lastSignInAt;
|
||||
|
||||
if (!lastSignInAt) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const date = new Date(lastSignInAt);
|
||||
return <span suppressHydrationWarning>{date.toLocaleString()}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
id: 'status',
|
||||
cell: ({ row }) => {
|
||||
const banDuration = row.original.banDuration;
|
||||
|
||||
if (!banDuration || banDuration === 'none') {
|
||||
return (
|
||||
<Badge className={'inline-flex'} color={'success'}>
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={'inline-flex'} color={'error'}>
|
||||
Banned
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const banDuration = row.original.banDuration;
|
||||
const isBanned = banDuration && banDuration !== 'none';
|
||||
|
||||
return (
|
||||
<div className={'flex justify-end'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={'icon'}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<EllipsisIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(user.id)}
|
||||
>
|
||||
Copy user ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={!isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${user.id}/impersonate`}>
|
||||
Impersonate User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={
|
||||
'text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/5'
|
||||
}
|
||||
href={`/admin/users/${user.id}/ban`}
|
||||
>
|
||||
Ban User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={
|
||||
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5'
|
||||
}
|
||||
href={`/admin/users/${user.id}/delete`}
|
||||
>
|
||||
Delete User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${user.id}/reactivate`}>
|
||||
Reactivate User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function UsersTable({
|
||||
users,
|
||||
page,
|
||||
pageCount,
|
||||
perPage,
|
||||
}: React.PropsWithChildren<{
|
||||
users: UserRow[];
|
||||
pageCount: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}>) {
|
||||
return (
|
||||
<DataTable
|
||||
tableProps={{
|
||||
'data-test': 'admin-users-table',
|
||||
}}
|
||||
pageIndex={page - 1}
|
||||
pageSize={perPage}
|
||||
pageCount={pageCount}
|
||||
data={users}
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersTable;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user