Next.js 15 Update (#26)

* Update Next.js and React versions in all packages
* Replace onRedirect function with next/link in BillingSessionStatus, since it's no longer cached by default
* Remove unused revalidatePath import in billing return page, since it's no longer cached by default
* Add Turbopack module aliases to improve development server speed
* Converted new Dynamic APIs to be Promise-based
* Adjust mobile layout
* Use ENABLE_REACT_COMPILER to enable the React Compiler in Next.js 15
* Report Errors using the new onRequestError hook
This commit is contained in:
Giancarlo Buomprisco
2024-10-22 08:39:21 +02:00
committed by GitHub
parent 93cb011260
commit 5b9285a575
109 changed files with 5143 additions and 5545 deletions

View File

@@ -12,8 +12,8 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.47.2",
"@types/node": "^22.5.5",
"@playwright/test": "^1.48.1",
"@types/node": "^22.7.8",
"node-html-parser": "^6.1.13"
}
}

View File

@@ -1,5 +1,3 @@
import { PageBody } from '@kit/ui/page';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';

View File

@@ -18,7 +18,10 @@ import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const ModeToggle = dynamic(
() => import('@kit/ui/mode-toggle').then((mod) => mod.ModeToggle),
() =>
import('@kit/ui/mode-toggle').then((mod) => ({
default: mod.ModeToggle,
})),
{
ssr: false,
},

View File

@@ -10,10 +10,10 @@ const getClassName = (path: string, currentPathName: string) => {
const isActive = isRouteActive(path, currentPathName);
return cn(
`text-sm font-medium transition-colors duration-300 inline-flex w-max`,
`inline-flex w-max text-sm font-medium transition-colors duration-300`,
{
'dark:text-gray-300 dark:hover:text-white': !isActive,
'dark:text-white text-current': isActive,
'text-current dark:text-white': isActive,
},
);
};

View File

@@ -10,6 +10,10 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post';
interface BlogPageProps {
params: Promise<{ slug: string }>;
}
const getPostBySlug = cache(postLoader);
async function postLoader(slug: string) {
@@ -20,10 +24,9 @@ async function postLoader(slug: string) {
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata | undefined> {
const post = await getPostBySlug(params.slug);
}: BlogPageProps): Promise<Metadata> {
const slug = (await params).slug;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
@@ -57,8 +60,9 @@ export async function generateMetadata({
});
}
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
async function BlogPost({ params }: BlogPageProps) {
const slug = (await params).slug;
const post = await getPostBySlug(slug);
if (!post) {
notFound();

View File

@@ -13,6 +13,10 @@ import { SitePageHeader } from '../_components/site-page-header';
import { BlogPagination } from './_components/blog-pagination';
import { PostPreview } from './_components/post-preview';
interface BlogPageProps {
searchParams: Promise<{ page?: string }>;
}
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
@@ -44,8 +48,9 @@ const getContentItems = cache(
},
);
async function BlogPage({ searchParams }: { searchParams: { page: string } }) {
async function BlogPage(props: BlogPageProps) {
const { t, resolvedLanguage: language } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const limit = 10;

View File

@@ -57,7 +57,7 @@ export function ContactForm() {
await sendContactEmail(data);
setState({ success: true, error: false });
} catch (error) {
} catch {
setState({ error: true, success: false });
}
});

View File

@@ -14,20 +14,19 @@ import { DocsCards } from '../_components/docs-cards';
const getPageBySlug = cache(pageLoader);
interface DocumentationPageProps {
params: Promise<{ slug: string[] }>;
}
async function pageLoader(slug: string) {
const client = await createCmsClient();
return client.getContentItemBySlug({ slug, collection: 'documentation' });
}
interface PageParams {
params: {
slug: string[];
};
}
export const generateMetadata = async ({ params }: PageParams) => {
const page = await getPageBySlug(params.slug.join('/'));
export const generateMetadata = async ({ params }: DocumentationPageProps) => {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
@@ -41,8 +40,9 @@ export const generateMetadata = async ({ params }: PageParams) => {
};
};
async function DocumentationPage({ params }: PageParams) {
const page = await getPageBySlug(params.slug.join('/'));
async function DocumentationPage({ params }: DocumentationPageProps) {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();

View File

@@ -175,7 +175,6 @@ function Home() {
);
}
export default withI18n(Home);
function MainCallToActionButton() {

View File

@@ -1,5 +1,3 @@
import { User } from '@supabase/supabase-js';
import { Home, Users } from 'lucide-react';
import {
@@ -12,7 +10,7 @@ import {
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
export function AdminSidebar(props: { user: User }) {
export function AdminSidebar() {
return (
<Sidebar>
<SidebarContent className={'py-4'}>
@@ -35,7 +33,7 @@ export function AdminSidebar(props: { user: User }) {
</SidebarContent>
<SidebarContent className={'absolute bottom-4'}>
<ProfileAccountDropdownContainer user={props.user} />
<ProfileAccountDropdownContainer />
</SidebarContent>
</Sidebar>
);

View File

@@ -6,12 +6,13 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'
import { PageBody } from '@kit/ui/page';
interface Params {
params: {
params: Promise<{
id: string;
};
}>;
}
export const generateMetadata = async ({ params }: Params) => {
export const generateMetadata = async (props: Params) => {
const params = await props.params;
const account = await loadAccount(params.id);
return {
@@ -19,7 +20,8 @@ export const generateMetadata = async ({ params }: Params) => {
};
};
async function AccountPage({ params }: Params) {
async function AccountPage(props: Params) {
const params = await props.params;
const account = await loadAccount(params.id);
return (

View File

@@ -11,12 +11,17 @@ interface SearchParams {
query?: string;
}
interface AdminAccountsPageProps {
searchParams: Promise<SearchParams>;
}
export const metadata = {
title: `Accounts`,
};
function AccountsPage({ searchParams }: { searchParams: SearchParams }) {
async function AccountsPage(props: AdminAccountsPageProps) {
const client = getSupabaseServerAdminClient();
const searchParams = await props.searchParams;
const page = searchParams.page ? parseInt(searchParams.page) : 1;
const filters = getFilters(searchParams);

View File

@@ -2,19 +2,16 @@ import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
export const metadata = {
title: `Super Admin`,
};
export default async function AdminLayout(props: React.PropsWithChildren) {
const user = await requireUserInServerComponent();
export default function AdminLayout(props: React.PropsWithChildren) {
return (
<Page style={'sidebar'}>
<PageNavigation>
<AdminSidebar user={user} />
<AdminSidebar />
</PageNavigation>
<PageMobileNavigation>

View File

@@ -15,7 +15,7 @@ export const POST = enhanceRouteHandler(
// return a successful response
return new Response(null, { status: 200 });
} catch (error) {
} catch {
// return an error response
return new Response(null, { status: 500 });
}

View File

@@ -8,15 +8,15 @@ import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Params {
searchParams: {
interface AuthCallbackErrorPageProps {
searchParams: Promise<{
error: string;
invite_token: string;
};
}>;
}
function AuthCallbackErrorPage({ searchParams }: Params) {
const { error, invite_token } = searchParams;
async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
const { error, invite_token } = await props.searchParams;
const queryParam = invite_token ? `?invite_token=${invite_token}` : '';
const signInPath = pathsConfig.auth.signIn + queryParam;

View File

@@ -11,9 +11,9 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
interface SignInPageProps {
searchParams: {
searchParams: Promise<{
invite_token?: string;
};
}>;
}
export const generateMetadata = async () => {
@@ -30,8 +30,8 @@ const paths = {
joinTeam: pathsConfig.app.joinTeam,
};
function SignInPage({ searchParams }: SignInPageProps) {
const inviteToken = searchParams.invite_token;
async function SignInPage({ searchParams }: SignInPageProps) {
const inviteToken = (await searchParams).invite_token;
const signUpPath =
pathsConfig.auth.signUp +

View File

@@ -19,9 +19,9 @@ export const generateMetadata = async () => {
};
interface Props {
searchParams: {
searchParams: Promise<{
invite_token?: string;
};
}>;
}
const paths = {
@@ -29,8 +29,8 @@ const paths = {
appHome: pathsConfig.app.home,
};
function SignUpPage({ searchParams }: Props) {
const inviteToken = searchParams.invite_token;
async function SignUpPage({ searchParams }: Props) {
const inviteToken = (await searchParams).invite_token;
const signInPath =
pathsConfig.auth.signIn +

View File

@@ -9,9 +9,9 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Props {
searchParams: {
searchParams: Promise<{
next?: string;
};
}>;
}
export const generateMetadata = async () => {
@@ -39,7 +39,8 @@ async function VerifyPage(props: Props) {
redirect(pathsConfig.auth.signIn);
}
const redirectPath = props.searchParams.next ?? pathsConfig.app.home;
const nextPath = (await props.searchParams).next;
const redirectPath = nextPath ?? pathsConfig.app.home;
return (
<MultiFactorChallengeContainer

View File

@@ -22,6 +22,7 @@ export function HomeAccountSelector(props: {
}>;
userId: string;
collisionPadding?: number;
}) {
const router = useRouter();
const { collapsed } = useContext(SidebarContext);
@@ -29,6 +30,7 @@ export function HomeAccountSelector(props: {
return (
<AccountSelector
collapsed={collapsed}
collisionPadding={props.collisionPadding ?? 20}
accounts={props.accounts}
features={features}
userId={props.userId}

View File

@@ -50,18 +50,12 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
<div className={'flex justify-end space-x-2.5'}>
<If condition={featuresFlagConfig.enableTeamAccounts}>
<HomeAccountSelector
userId={user.id}
accounts={accounts}
/>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</If>
<UserNotifications userId={user.id} />
<ProfileAccountDropdownContainer
user={user}
account={workspace}
/>
<ProfileAccountDropdownContainer user={user} account={workspace} />
</div>
</div>
);

View File

@@ -71,6 +71,7 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
collisionPadding={0}
/>
</DropdownMenuGroup>

View File

@@ -99,7 +99,7 @@ export function PersonalAccountCheckoutForm(props: {
});
setCheckoutToken(checkoutToken);
} catch (e) {
} catch {
setError(true);
}
});

View File

@@ -23,7 +23,7 @@ import { loadUserWorkspace } from './_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const style = getLayoutStyle();
const style = use(getLayoutStyle());
return (
<Page style={style}>
@@ -52,9 +52,11 @@ function UserHomeLayout({ children }: React.PropsWithChildren) {
export default withI18n(UserHomeLayout);
function getLayoutStyle() {
async function getLayoutStyle() {
const cookieStore = await cookies();
return (
(cookies().get('layout-style')?.value as PageLayoutStyle) ??
(cookieStore.get('layout-style')?.value as PageLayoutStyle) ??
personalAccountNavigationConfig.style
);
}
}

View File

@@ -6,17 +6,20 @@ import { PageBody } from '@kit/ui/page';
import authConfig from '~/config/auth.config';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
const features = {
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enablePasswordUpdate: authConfig.providers.password,
};
const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome;
const paths = {
callback: pathsConfig.auth.callback + `?next=${pathsConfig.app.accountHome}`,
callback: callbackPath + `?next=${accountHomePath}`,
};
export const generateMetadata = async () => {
@@ -29,7 +32,7 @@ export const generateMetadata = async () => {
};
function PersonalAccountSettingsPage() {
const { user } = use(loadUserWorkspace());
const user = use(requireUserInServerComponent());
return (
<PageBody>

View File

@@ -0,0 +1,896 @@
'use client';
import { useMemo, useState } from 'react';
import { ArrowDown, ArrowUp, Menu, TrendingUp } from 'lucide-react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
XAxis,
} from 'recharts';
import { Badge } from '@kit/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@kit/ui/chart';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
export default function DashboardDemo() {
const mrr = useMemo(() => generateDemoData(), []);
const netRevenue = useMemo(() => generateDemoData(), []);
const fees = useMemo(() => generateDemoData(), []);
const newCustomers = useMemo(() => generateDemoData(), []);
return (
<div
className={
'flex flex-col space-y-4 pb-36 duration-500 animate-in fade-in'
}
>
<div
className={
'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
}
>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>MRR</span>
<Trend trend={'up'}>20%</Trend>
</CardTitle>
<CardDescription>
<span>Monthly recurring revenue</span>
</CardDescription>
<div>
<Figure>{`$${mrr[1]}`}</Figure>
</div>
</CardHeader>
<CardContent className={'space-y-4'}>
<Chart data={mrr[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>Revenue</span>
<Trend trend={'up'}>12%</Trend>
</CardTitle>
<CardDescription>
<span>Total revenue including fees</span>
</CardDescription>
<div>
<Figure>{`$${netRevenue[1]}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={netRevenue[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>Fees</span>
<Trend trend={'up'}>9%</Trend>
</CardTitle>
<CardDescription>
<span>Total fees collected</span>
</CardDescription>
<div>
<Figure>{`$${fees[1]}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={fees[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>New Customers</span>
<Trend trend={'down'}>-25%</Trend>
</CardTitle>
<CardDescription>
<span>Customers who signed up this month</span>
</CardDescription>
<div>
<Figure>{`${Number(newCustomers[1]).toFixed(0)}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={newCustomers[0]} />
</CardContent>
</Card>
</div>
<VisitorsChart />
<PageViewsChart />
<div>
<Card>
<CardHeader>
<CardTitle>Best Customers</CardTitle>
<CardDescription>Showing the top customers by MRR</CardDescription>
</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),
});
}
const lastValue = data[data.length - 1]?.value;
return [data, lastValue] as [typeof data, string];
}
function Chart(
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
) {
const chartConfig = {
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Line
dataKey="value"
type="natural"
stroke="var(--color-desktop)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
);
}
function CustomersTable() {
const customers = [
{
name: 'John Doe',
email: 'john@makerkit.dev',
plan: 'Pro',
mrr: '$120.5',
logins: 1020,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emma Smith',
email: 'emma@makerit.dev',
plan: 'Basic',
mrr: '$65.4',
logins: 570,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Robert Johnson',
email: 'robert@makerkit.dev',
plan: 'Pro',
mrr: '$500.1',
logins: 2050,
status: 'Healthy',
trend: 'up',
},
{
name: 'Olivia Brown',
email: 'olivia@makerkit.dev',
plan: 'Basic',
mrr: '$10',
logins: 50,
status: 'Churn',
trend: 'down',
},
{
name: 'Michael Davis',
email: 'michael@makerkit.dev',
plan: 'Pro',
mrr: '$300.2',
logins: 1520,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emily Jones',
email: 'emily@makerkit.dev',
plan: 'Pro',
mrr: '$75.7',
logins: 780,
status: 'Healthy',
trend: 'up',
},
{
name: 'Daniel Garcia',
email: 'daniel@makerkit.dev',
plan: 'Basic',
mrr: '$50',
logins: 320,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Liam Miller',
email: 'liam@makerkit.dev',
plan: 'Pro',
mrr: '$90.8',
logins: 1260,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emma Clark',
email: 'emma@makerkit.dev',
plan: 'Basic',
mrr: '$0',
logins: 20,
status: 'Churn',
trend: 'down',
},
{
name: 'Elizabeth Rodriguez',
email: 'liz@makerkit.dev',
plan: 'Pro',
mrr: '$145.3',
logins: 1380,
status: 'Healthy',
trend: 'up',
},
{
name: 'James Martinez',
email: 'james@makerkit.dev',
plan: 'Pro',
mrr: '$120.5',
logins: 940,
status: 'Healthy',
trend: 'up',
},
{
name: 'Charlotte Ryan',
email: 'carlotte@makerkit.dev',
plan: 'Basic',
mrr: '$80.6',
logins: 460,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Lucas Evans',
email: 'lucas@makerkit.dev',
plan: 'Pro',
mrr: '$210.3',
logins: 1850,
status: 'Healthy',
trend: 'up',
},
{
name: 'Sophia Wilson',
email: 'sophia@makerkit.dev',
plan: 'Basic',
mrr: '$10',
logins: 35,
status: 'Churn',
trend: 'down',
},
{
name: 'William Kelly',
email: 'will@makerkit.dev',
plan: 'Pro',
mrr: '$350.2',
logins: 1760,
status: 'Healthy',
trend: 'up',
},
{
name: 'Oliver Thomas',
email: 'olly@makerkit.dev',
plan: 'Pro',
mrr: '$145.6',
logins: 1350,
status: 'Healthy',
trend: 'up',
},
{
name: 'Samantha White',
email: 'sam@makerkit.dev',
plan: 'Basic',
mrr: '$60.3',
logins: 425,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Benjamin Lewis',
email: 'ben@makerkit.dev',
plan: 'Pro',
mrr: '$175.8',
logins: 1600,
status: 'Healthy',
trend: 'up',
},
{
name: 'Zoe Harris',
email: 'zoe@makerkit.dev',
plan: 'Basic',
mrr: '$0',
logins: 18,
status: 'Churn',
trend: 'down',
},
{
name: 'Zachary Nelson',
email: 'zac@makerkit.dev',
plan: 'Pro',
mrr: '$255.9',
logins: 1785,
status: 'Healthy',
trend: 'up',
},
];
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
<TableHead>MRR</TableHead>
<TableHead>Logins</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((customer) => (
<TableRow key={customer.name}>
<TableCell className={'flex flex-col'}>
<span>{customer.name}</span>
<span className={'text-sm text-muted-foreground'}>
{customer.email}
</span>
</TableCell>
<TableCell>{customer.plan}</TableCell>
<TableCell>{customer.mrr}</TableCell>
<TableCell>{customer.logins}</TableCell>
<TableCell>
<BadgeWithTrend trend={customer.trend}>
{customer.status}
</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'}
className={'border-transparent px-1.5 font-normal'}
>
<span className={className}>{props.children}</span>
</Badge>
);
}
function Figure(props: React.PropsWithChildren) {
return (
<div className={'font-heading text-2xl font-semibold'}>
{props.children}
</div>
);
}
function Trend(
props: React.PropsWithChildren<{
trend: 'up' | 'down' | 'stale';
}>,
) {
const Icon = useMemo(() => {
switch (props.trend) {
case 'up':
return <ArrowUp className={'h-3 w-3 text-green-500'} />;
case 'down':
return <ArrowDown className={'h-3 w-3 text-destructive'} />;
case 'stale':
return <Menu className={'h-3 w-3 text-orange-500'} />;
}
}, [props.trend]);
return (
<div>
<BadgeWithTrend trend={props.trend}>
<span className={'flex items-center space-x-1'}>
{Icon}
<span>{props.children}</span>
</span>
</BadgeWithTrend>
</div>
);
}
export function VisitorsChart() {
const chartData = useMemo(
() => [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
],
[],
);
const chartConfig = {
visitors: {
label: 'Visitors',
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
return (
<Card>
<CardHeader>
<CardTitle>Visitors</CardTitle>
<CardDescription>
Showing total visitors for the last 6 months
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer className={'h-64 w-full'} config={chartConfig}>
<AreaChart accessibilityLayer data={chartData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
fillOpacity={0.4}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
<CardFooter>
<div className="flex w-full items-start gap-2 text-sm">
<div className="grid gap-2">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="flex items-center gap-2 leading-none text-muted-foreground">
January - June 2024
</div>
</div>
</div>
</CardFooter>
</Card>
);
}
export function PageViewsChart() {
const [activeChart, setActiveChart] =
useState<keyof typeof chartConfig>('desktop');
const chartData = [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
];
const chartConfig = {
views: {
label: 'Page Views',
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
const total = useMemo(
() => ({
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
}),
[],
);
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Page Views</CardTitle>
<CardDescription>
Showing total visitors for the last 3 months
</CardDescription>
</div>
<div className="flex">
{['desktop', 'mobile'].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{total[key as keyof typeof total].toLocaleString()}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-64 w-full"
>
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}}
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -1,896 +1,17 @@
'use client';
import { useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { ArrowDown, ArrowUp, Menu, TrendingUp } from 'lucide-react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
XAxis,
} from 'recharts';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { Badge } from '@kit/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@kit/ui/chart';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
export default function DashboardDemo() {
const mrr = useMemo(() => generateDemoData(), []);
const netRevenue = useMemo(() => generateDemoData(), []);
const fees = useMemo(() => generateDemoData(), []);
const newCustomers = useMemo(() => generateDemoData(), []);
return (
<div
className={
'flex flex-col space-y-4 pb-36 duration-500 animate-in fade-in'
}
>
<div
className={
'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
}
>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>MRR</span>
<Trend trend={'up'}>20%</Trend>
</CardTitle>
<CardDescription>
<span>Monthly recurring revenue</span>
</CardDescription>
<div>
<Figure>{`$${mrr[1]}`}</Figure>
</div>
</CardHeader>
<CardContent className={'space-y-4'}>
<Chart data={mrr[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>Revenue</span>
<Trend trend={'up'}>12%</Trend>
</CardTitle>
<CardDescription>
<span>Total revenue including fees</span>
</CardDescription>
<div>
<Figure>{`$${netRevenue[1]}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={netRevenue[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>Fees</span>
<Trend trend={'up'}>9%</Trend>
</CardTitle>
<CardDescription>
<span>Total fees collected</span>
</CardDescription>
<div>
<Figure>{`$${fees[1]}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={fees[0]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className={'flex items-center gap-2.5'}>
<span>New Customers</span>
<Trend trend={'down'}>-25%</Trend>
</CardTitle>
<CardDescription>
<span>Customers who signed up this month</span>
</CardDescription>
<div>
<Figure>{`${Number(newCustomers[1]).toFixed(0)}`}</Figure>
</div>
</CardHeader>
<CardContent>
<Chart data={newCustomers[0]} />
</CardContent>
</Card>
</div>
<VisitorsChart />
<PageViewsChart />
<div>
<Card>
<CardHeader>
<CardTitle>Best Customers</CardTitle>
<CardDescription>Showing the top customers by MRR</CardDescription>
</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),
});
}
const lastValue = data[data.length - 1]?.value;
return [data, lastValue] as [typeof data, string];
}
function Chart(
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
) {
const chartConfig = {
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Line
dataKey="value"
type="natural"
stroke="var(--color-desktop)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
);
}
function CustomersTable() {
const customers = [
{
name: 'John Doe',
email: 'john@makerkit.dev',
plan: 'Pro',
mrr: '$120.5',
logins: 1020,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emma Smith',
email: 'emma@makerit.dev',
plan: 'Basic',
mrr: '$65.4',
logins: 570,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Robert Johnson',
email: 'robert@makerkit.dev',
plan: 'Pro',
mrr: '$500.1',
logins: 2050,
status: 'Healthy',
trend: 'up',
},
{
name: 'Olivia Brown',
email: 'olivia@makerkit.dev',
plan: 'Basic',
mrr: '$10',
logins: 50,
status: 'Churn',
trend: 'down',
},
{
name: 'Michael Davis',
email: 'michael@makerkit.dev',
plan: 'Pro',
mrr: '$300.2',
logins: 1520,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emily Jones',
email: 'emily@makerkit.dev',
plan: 'Pro',
mrr: '$75.7',
logins: 780,
status: 'Healthy',
trend: 'up',
},
{
name: 'Daniel Garcia',
email: 'daniel@makerkit.dev',
plan: 'Basic',
mrr: '$50',
logins: 320,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Liam Miller',
email: 'liam@makerkit.dev',
plan: 'Pro',
mrr: '$90.8',
logins: 1260,
status: 'Healthy',
trend: 'up',
},
{
name: 'Emma Clark',
email: 'emma@makerkit.dev',
plan: 'Basic',
mrr: '$0',
logins: 20,
status: 'Churn',
trend: 'down',
},
{
name: 'Elizabeth Rodriguez',
email: 'liz@makerkit.dev',
plan: 'Pro',
mrr: '$145.3',
logins: 1380,
status: 'Healthy',
trend: 'up',
},
{
name: 'James Martinez',
email: 'james@makerkit.dev',
plan: 'Pro',
mrr: '$120.5',
logins: 940,
status: 'Healthy',
trend: 'up',
},
{
name: 'Charlotte Ryan',
email: 'carlotte@makerkit.dev',
plan: 'Basic',
mrr: '$80.6',
logins: 460,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Lucas Evans',
email: 'lucas@makerkit.dev',
plan: 'Pro',
mrr: '$210.3',
logins: 1850,
status: 'Healthy',
trend: 'up',
},
{
name: 'Sophia Wilson',
email: 'sophia@makerkit.dev',
plan: 'Basic',
mrr: '$10',
logins: 35,
status: 'Churn',
trend: 'down',
},
{
name: 'William Kelly',
email: 'will@makerkit.dev',
plan: 'Pro',
mrr: '$350.2',
logins: 1760,
status: 'Healthy',
trend: 'up',
},
{
name: 'Oliver Thomas',
email: 'olly@makerkit.dev',
plan: 'Pro',
mrr: '$145.6',
logins: 1350,
status: 'Healthy',
trend: 'up',
},
{
name: 'Samantha White',
email: 'sam@makerkit.dev',
plan: 'Basic',
mrr: '$60.3',
logins: 425,
status: 'Possible Churn',
trend: 'stale',
},
{
name: 'Benjamin Lewis',
email: 'ben@makerkit.dev',
plan: 'Pro',
mrr: '$175.8',
logins: 1600,
status: 'Healthy',
trend: 'up',
},
{
name: 'Zoe Harris',
email: 'zoe@makerkit.dev',
plan: 'Basic',
mrr: '$0',
logins: 18,
status: 'Churn',
trend: 'down',
},
{
name: 'Zachary Nelson',
email: 'zac@makerkit.dev',
plan: 'Pro',
mrr: '$255.9',
logins: 1785,
status: 'Healthy',
trend: 'up',
},
];
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
<TableHead>MRR</TableHead>
<TableHead>Logins</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((customer) => (
<TableRow key={customer.name}>
<TableCell className={'flex flex-col'}>
<span>{customer.name}</span>
<span className={'text-sm text-muted-foreground'}>
{customer.email}
</span>
</TableCell>
<TableCell>{customer.plan}</TableCell>
<TableCell>{customer.mrr}</TableCell>
<TableCell>{customer.logins}</TableCell>
<TableCell>
<BadgeWithTrend trend={customer.trend}>
{customer.status}
</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'}
className={'border-transparent px-1.5 font-normal'}
>
<span className={className}>{props.children}</span>
</Badge>
);
}
function Figure(props: React.PropsWithChildren) {
return (
<div className={'font-heading text-2xl font-semibold'}>
{props.children}
</div>
);
}
function Trend(
props: React.PropsWithChildren<{
trend: 'up' | 'down' | 'stale';
}>,
) {
const Icon = useMemo(() => {
switch (props.trend) {
case 'up':
return <ArrowUp className={'h-3 w-3 text-green-500'} />;
case 'down':
return <ArrowDown className={'h-3 w-3 text-destructive'} />;
case 'stale':
return <Menu className={'h-3 w-3 text-orange-500'} />;
}
}, [props.trend]);
return (
<div>
<BadgeWithTrend trend={props.trend}>
<span className={'flex items-center space-x-1'}>
{Icon}
<span>{props.children}</span>
</span>
</BadgeWithTrend>
</div>
);
}
export function VisitorsChart() {
const chartData = useMemo(
() => [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
],
[],
);
const chartConfig = {
visitors: {
label: 'Visitors',
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
return (
<Card>
<CardHeader>
<CardTitle>Visitors</CardTitle>
<CardDescription>
Showing total visitors for the last 6 months
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer className={'h-64 w-full'} config={chartConfig}>
<AreaChart accessibilityLayer data={chartData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
fillOpacity={0.4}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
<CardFooter>
<div className="flex w-full items-start gap-2 text-sm">
<div className="grid gap-2">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="flex items-center gap-2 leading-none text-muted-foreground">
January - June 2024
</div>
</div>
</div>
</CardFooter>
</Card>
);
}
export function PageViewsChart() {
const [activeChart, setActiveChart] =
useState<keyof typeof chartConfig>('desktop');
const chartData = [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
];
const chartConfig = {
views: {
label: 'Page Views',
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
const total = useMemo(
() => ({
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
}),
[],
);
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Page Views</CardTitle>
<CardDescription>
Showing total visitors for the last 3 months
</CardDescription>
</div>
<div className="flex">
{['desktop', 'mobile'].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{total[key as keyof typeof total].toLocaleString()}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-64 w-full"
>
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}}
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
export const DashboardDemo = dynamic(() => import('./dashboard-demo-charts'), {
ssr: false,
loading: () => (
<LoadingOverlay>
<span className={'text-muted-foreground'}>
<Trans i18nKey={'common:loading'} />
</span>
</LoadingOverlay>
),
});

View File

@@ -166,6 +166,7 @@ function TeamAccountsModal(props: { accounts: Accounts; userId: string }) {
<div className={'py-16'}>
<AccountSelector
className={'w-full max-w-full'}
collisionPadding={0}
userId={props.userId}
onAccountChange={(value) => {
const path = value

View File

@@ -87,9 +87,7 @@ function SidebarContainer(props: {
<div className={'absolute bottom-4 left-0 w-full'}>
<SidebarContent>
<ProfileAccountDropdownContainer
user={props.user}
/>
<ProfileAccountDropdownContainer user={props.user} />
</SidebarContent>
</div>
</>

View File

@@ -61,10 +61,7 @@ export function TeamAccountNavigationMenu(props: {
<TeamAccountNotifications accountId={account.id} userId={user.id} />
<ProfileAccountDropdownContainer
user={user}
account={account}
/>
<ProfileAccountDropdownContainer user={user} account={account} />
</div>
</div>
);

View File

@@ -0,0 +1,14 @@
'use client';
import dynamic from 'next/dynamic';
export const EmbeddedCheckoutForm = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return EmbeddedCheckout;
},
{
ssr: false,
},
);

View File

@@ -25,7 +25,7 @@ const enabled = featureFlagsConfig.enableTeamAccountBilling;
* @description Creates a checkout session for a team account.
*/
export const createTeamAccountCheckoutSession = enhanceAction(
(data) => {
async (data) => {
if (!enabled) {
throw new Error('Team account billing is not enabled');
}

View File

@@ -229,6 +229,7 @@ class TeamBillingService {
customerId,
accountId,
name: this.namespace,
error,
},
`Billing Portal session was not created`,
);
@@ -288,7 +289,7 @@ class TeamBillingService {
`Encountered an error while fetching the number of existing seats`,
);
return Promise.reject(error);
return Promise.reject(error as Error);
}
}
}

View File

@@ -23,10 +23,8 @@ import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'
import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form';
import { createBillingPortalSession } from './_lib/server/server-actions';
interface Params {
params: {
account: string;
};
interface TeamAccountBillingPageProps {
params: Promise<{ account: string }>;
}
export const generateMetadata = async () => {
@@ -38,8 +36,9 @@ export const generateMetadata = async () => {
};
};
async function TeamAccountBillingPage({ params }: Params) {
const workspace = await loadTeamWorkspace(params.account);
async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
const account = (await params).account;
const workspace = await loadTeamWorkspace(account);
const accountId = workspace.account.id;
const [data, customerId] = await loadTeamAccountBillingPage(accountId);
@@ -65,7 +64,7 @@ async function TeamAccountBillingPage({ params }: Params) {
return (
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={params.account} />
<input type="hidden" name={'slug'} value={account} />
<BillingPortalCard />
</form>
@@ -75,7 +74,7 @@ async function TeamAccountBillingPage({ params }: Params) {
return (
<>
<TeamAccountLayoutPageHeader
account={params.account}
account={account}
title={<Trans i18nKey={'common:routes.billing'} />}
description={<AppBreadcrumbs />}
/>

View File

@@ -1,5 +1,3 @@
import { revalidatePath } from 'next/cache';
import dynamic from 'next/dynamic';
import { notFound, redirect } from 'next/navigation';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
@@ -10,25 +8,16 @@ import billingConfig from '~/config/billing.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { EmbeddedCheckoutForm } from '../_components/embedded-checkout-form';
interface SessionPageProps {
searchParams: {
searchParams: Promise<{
session_id: string;
};
}>;
}
const LazyEmbeddedCheckout = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return EmbeddedCheckout;
},
{
ssr: false,
},
);
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
const sessionId = searchParams.session_id;
const sessionId = (await searchParams).session_id;
if (!sessionId) {
redirect('../');
@@ -38,7 +27,7 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
if (checkoutToken) {
return (
<LazyEmbeddedCheckout
<EmbeddedCheckoutForm
checkoutToken={checkoutToken}
provider={billingConfig.provider}
/>
@@ -49,7 +38,7 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
<>
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
<BillingSessionStatus
onRedirect={onRedirect}
redirectPath={'../billing'}
customerEmail={customerEmail ?? ''}
/>
</div>
@@ -96,19 +85,3 @@ async function loadCheckoutSession(sessionId: string) {
checkoutToken,
};
}
/**
* Revalidates the layout to update cached pages
* and redirects back to the home page.
*/
// eslint-disable-next-line @typescript-eslint/require-await
async function onRedirect() {
'use server';
// revalidate the home page to update cached pages
// which may have changed due to the billing session
revalidatePath('/home', 'layout');
// redirect back to billing page
redirect('../billing');
}

View File

@@ -21,18 +21,14 @@ import { TeamAccountLayoutSidebar } from './_components/team-account-layout-side
import { TeamAccountNavigationMenu } from './_components/team-account-navigation-menu';
import { loadTeamWorkspace } from './_lib/server/team-account-workspace.loader';
interface TeamWorkspaceLayoutParams {
account: string;
}
type TeamWorkspaceLayoutProps = React.PropsWithChildren<{
params: Promise<{ account: string }>;
}>;
function TeamWorkspaceLayout({
children,
params,
}: React.PropsWithChildren<{
params: TeamWorkspaceLayoutParams;
}>) {
const data = use(loadTeamWorkspace(params.account));
const style = getLayoutStyle(params.account);
function TeamWorkspaceLayout({ children, params }: TeamWorkspaceLayoutProps) {
const account = use(params).account;
const data = use(loadTeamWorkspace(account));
const style = use(getLayoutStyle(account));
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
label: name,
@@ -45,7 +41,7 @@ function TeamWorkspaceLayout({
<PageNavigation>
<If condition={style === 'sidebar'}>
<TeamAccountLayoutSidebar
account={params.account}
account={account}
accountId={data.account.id}
accounts={accounts}
user={data.user}
@@ -64,7 +60,7 @@ function TeamWorkspaceLayout({
<TeamAccountLayoutMobileNavigation
userId={data.user.id}
accounts={accounts}
account={params.account}
account={account}
/>
</div>
</PageMobileNavigation>
@@ -76,9 +72,11 @@ function TeamWorkspaceLayout({
);
}
function getLayoutStyle(account: string) {
async function getLayoutStyle(account: string) {
const cookieStore = await cookies();
return (
(cookies().get('layout-style')?.value as PageLayoutStyle) ??
(cookieStore.get('layout-style')?.value as PageLayoutStyle) ??
getTeamAccountSidebarConfig(account).style
);
}

View File

@@ -26,10 +26,8 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
import { loadMembersPageData } from './_lib/server/members-page.loader';
interface Params {
params: {
account: string;
};
interface TeamAccountMembersPageProps {
params: Promise<{ account: string }>;
}
export const generateMetadata = async () => {
@@ -41,11 +39,12 @@ export const generateMetadata = async () => {
};
};
async function TeamAccountMembersPage({ params }: Params) {
async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) {
const client = getSupabaseServerClient();
const slug = (await params).account;
const [members, invitations, canAddMember, { user, account }] =
await loadMembersPageData(client, params.account);
await loadMembersPageData(client, slug);
const canManageRoles = account.permissions.includes('roles.manage');
const canManageInvitations = account.permissions.includes('invites.manage');

View File

@@ -1,33 +1,19 @@
import loadDynamic from 'next/dynamic';
import { use } from 'react';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { DashboardDemo } from './_components/dashboard-demo';
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
interface Params {
account: string;
interface TeamAccountHomePageProps {
params: Promise<{ account: string }>;
}
const DashboardDemo = loadDynamic(
() => import('./_components/dashboard-demo'),
{
ssr: false,
loading: () => (
<LoadingOverlay>
<span className={'text-muted-foreground'}>
<Trans i18nKey={'common:loading'} />
</span>
</LoadingOverlay>
),
},
);
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('teams:home.pageTitle');
@@ -37,11 +23,13 @@ export const generateMetadata = async () => {
};
};
function TeamAccountHomePage({ params }: { params: Params }) {
function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
const account = use(params).account;
return (
<>
<TeamAccountLayoutPageHeader
account={params.account}
account={account}
title={<Trans i18nKey={'common:routes.dashboard'} />}
description={<AppBreadcrumbs />}
/>

View File

@@ -21,19 +21,18 @@ export const generateMetadata = async () => {
};
};
interface Props {
params: {
account: string;
};
interface TeamAccountSettingsPageProps {
params: Promise<{ account: string }>;
}
const paths = {
teamAccountSettings: pathsConfig.app.accountSettings,
};
async function TeamAccountSettingsPage(props: Props) {
async function TeamAccountSettingsPage(props: TeamAccountSettingsPageProps) {
const api = createTeamAccountsApi(getSupabaseServerClient());
const data = await api.getTeamAccount(props.params.account);
const slug = (await props.params).account;
const data = await api.getTeamAccount(slug);
const account = {
id: data.id,

View File

@@ -18,11 +18,11 @@ import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Context {
searchParams: {
interface JoinTeamAccountPageProps {
searchParams: Promise<{
invite_token?: string;
email?: string;
};
}>;
}
export const generateMetadata = async () => {
@@ -33,7 +33,8 @@ export const generateMetadata = async () => {
};
};
async function JoinTeamAccountPage({ searchParams }: Context) {
async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
const searchParams = await props.searchParams;
const token = searchParams.invite_token;
// no token, redirect to 404

View File

@@ -16,7 +16,7 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const { language } = await createI18nServerInstance();
const theme = getTheme();
const theme = await getTheme();
const className = getClassName(theme);
return (
@@ -51,8 +51,9 @@ function getClassName(theme?: string) {
});
}
function getTheme() {
return cookies().get('theme')?.value as 'light' | 'dark' | 'system';
async function getTheme() {
const cookiesStore = await cookies();
return cookiesStore.get('theme')?.value as 'light' | 'dark' | 'system';
}
export const generateMetadata = generateRootMetadata;

View File

@@ -2,15 +2,15 @@
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { analytics } from '@kit/analytics';
import { AppEvent, AppEventType, ConsumerProvidedEventTypes, useAppEvents } from '@kit/shared/events';
import {
AppEvent,
AppEventType,
ConsumerProvidedEventTypes,
useAppEvents,
} from '@kit/shared/events';
type AnalyticsMapping<
T extends ConsumerProvidedEventTypes = NonNullable<unknown>,

View File

@@ -18,7 +18,7 @@ const features = {
};
export function ProfileAccountDropdownContainer(props: {
user: User;
user?: User;
account?: {
id: string | null;
@@ -28,7 +28,11 @@ export function ProfileAccountDropdownContainer(props: {
}) {
const signOut = useSignOut();
const user = useUser(props.user);
const userData = user.data as User;
const userData = user.data;
if (!userData) {
return null;
}
return (
<PersonalAccountDropdown

View File

@@ -2,6 +2,8 @@
* This file is used to register monitoring instrumentation
* for your Next.js application.
*/
import { type Instrumentation } from 'next';
export async function register() {
const { registerMonitoringInstrumentation } = await import(
'@kit/monitoring/instrumentation'
@@ -11,3 +13,17 @@ export async function register() {
// based on the MONITORING_PROVIDER environment variable.
await registerMonitoringInstrumentation();
}
/**
* @name onRequestError
* @description This function is called when an error occurs during the request lifecycle.
* It is used to capture the error and send it to the monitoring service.
* @param err
*/
export const onRequestError: Instrumentation.onRequestError = async (err) => {
const { getServerMonitoringService } = await import('@kit/monitoring/server');
const service = await getServerMonitoringService();
await service.captureException(err as Error);
};

View File

@@ -0,0 +1,12 @@
/*
Mock modules for development.
This file is used to mock the modules that are not needed during development (unless they are used).
It allows the development server to load faster by not loading the modules that are not needed.
*/
export const Turnstile = undefined;
export const TurnstileProps = {};
export const useBaselimeRum = undefined;
export const BaselimeRum = undefined;

View File

@@ -30,8 +30,9 @@ const priority = featuresFlagConfig.languagePriority;
*
* Initialize the i18n instance for every RSC server request (eg. each page/layout)
*/
function createInstance() {
const cookie = cookies().get(I18N_COOKIE_NAME)?.value;
async function createInstance() {
const cookieStore = await cookies();
const cookie = cookieStore.get(I18N_COOKIE_NAME)?.value;
let selectedLanguage: string | undefined = undefined;
@@ -43,7 +44,7 @@ function createInstance() {
// if not, check if the language priority is set to user and
// use the user's preferred language
if (!selectedLanguage && priority === 'user') {
const userPreferredLanguage = getPreferredLanguageFromBrowser();
const userPreferredLanguage = await getPreferredLanguageFromBrowser();
selectedLanguage = getLanguageOrFallback(userPreferredLanguage);
}
@@ -55,8 +56,9 @@ function createInstance() {
export const createI18nServerInstance = cache(createInstance);
function getPreferredLanguageFromBrowser() {
const acceptLanguage = headers().get('accept-language');
async function getPreferredLanguageFromBrowser() {
const headersStore = await headers();
const acceptLanguage = headersStore.get('accept-language');
if (!acceptLanguage) {
return;

View File

@@ -8,8 +8,9 @@ import appConfig from '~/config/app.config';
* @name generateRootMetadata
* @description Generates the root metadata for the application
*/
export const generateRootMetadata = (): Metadata => {
const csrfToken = headers().get('x-csrf-token') ?? '';
export const generateRootMetadata = async (): Promise<Metadata> => {
const headersStore = await headers();
const csrfToken = headersStore.get('x-csrf-token') ?? '';
return {
title: appConfig.title,

View File

@@ -1,7 +1,6 @@
import withBundleAnalyzer from '@next/bundle-analyzer';
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const ENABLE_REACT_COMPILER = process.env.ENABLE_REACT_COMPILER === 'true';
const INTERNAL_PACKAGES = [
'@kit/ui',
@@ -35,15 +34,17 @@ const config = {
fullUrl: true,
},
},
serverExternalPackages: [],
// needed for supporting dynamic imports for local content
outputFileTracingIncludes: {
'/*': ['./content/**/*'],
},
experimental: {
mdxRs: true,
instrumentationHook: true,
reactCompiler: ENABLE_REACT_COMPILER,
turbo: {
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx'],
},
// needed for supporting dynamic imports for local content
outputFileTracingIncludes: {
'/*': ['./content/**/*'],
resolveAlias: getModulesAliases(),
},
optimizePackageImports: [
'recharts',
@@ -65,13 +66,10 @@ const config = {
typescript: { ignoreBuildErrors: true },
};
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(config);
export default config;
function getRemotePatterns() {
/** @type {import('next').NextConfig['remotePatterns']} */
// add here the remote patterns for your images
const remotePatterns = [];
if (SUPABASE_URL) {
@@ -96,3 +94,55 @@ function getRemotePatterns() {
},
];
}
/**
* @description Aliases modules based on the environment variables
* This will speed up the development server by not loading the modules that are not needed
* @returns {Record<string, string>}
*/
function getModulesAliases() {
if (process.env.NODE_ENV === 'production') {
return {};
}
const monitoringProvider = process.env.NEXT_PUBLIC_MONITORING_PROVIDER;
const billingProvider = process.env.NEXT_PUBLIC_BILLING_PROVIDER;
const mailerProvider = process.env.MAILER_PROVIDER;
const captchaProvider = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY;
// exclude the modules that are not needed
const excludeSentry = monitoringProvider !== 'sentry';
const excludeBaselime = monitoringProvider !== 'baselime';
const excludeStripe = billingProvider !== 'stripe';
const excludeNodemailer = mailerProvider !== 'nodemailer';
const excludeTurnstile = !captchaProvider;
/** @type {Record<string, string>} */
const aliases = {};
// the path to the noop module
const noopPath = '~/lib/dev-mock-modules';
if (excludeSentry) {
aliases['@sentry/nextjs'] = noopPath;
}
if (excludeBaselime) {
aliases['@baselime/react-rum'] = noopPath;
}
if (excludeStripe) {
aliases['stripe'] = noopPath;
aliases['@stripe/stripe-js'] = noopPath;
}
if (excludeNodemailer) {
aliases['nodemailer'] = noopPath;
}
if (excludeTurnstile) {
aliases['@marsidev/react-turnstile'] = noopPath;
}
return aliases;
}

View File

@@ -10,7 +10,7 @@
"build:test": "NODE_ENV=test pnpm with-env:test next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev --turbo",
"next:lint": "next lint",
"lint": "next lint && eslint .",
"format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"",
"start": "pnpm with-env next start",
"start:test": "NODE_ENV=test pnpm with-env:test next start",
@@ -55,21 +55,21 @@
"@makerkit/data-loader-supabase-nextjs": "^1.2.3",
"@marsidev/react-turnstile": "^1.0.2",
"@radix-ui/react-icons": "^1.3.0",
"@supabase/supabase-js": "^2.45.4",
"@tanstack/react-query": "5.56.2",
"@supabase/supabase-js": "^2.45.6",
"@tanstack/react-query": "5.59.15",
"@tanstack/react-table": "^8.20.5",
"date-fns": "^4.1.0",
"lucide-react": "^0.446.0",
"next": "14.2.13",
"lucide-react": "^0.453.0",
"next": "15.0.0",
"next-sitemap": "^4.2.3",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^15.0.2",
"recharts": "^2.12.7",
"react": "19.0.0-rc-69d4b800-20241021",
"react-dom": "19.0.0-rc-69d4b800-20241021",
"react-hook-form": "^7.53.1",
"react-i18next": "^15.1.0",
"recharts": "2.13.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -77,18 +77,21 @@
"@kit/prettier-config": "workspace:^",
"@kit/tailwind-config": "workspace:^",
"@kit/tsconfig": "workspace:^",
"@next/bundle-analyzer": "14.2.13",
"@next/bundle-analyzer": "15.0.0",
"@types/mdx": "^2.0.13",
"@types/node": "^22.5.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.7.8",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
"dotenv-cli": "^7.4.2",
"eslint": "^8.57.0",
"import-in-the-middle": "1.11.2",
"prettier": "^3.3.3",
"supabase": "^1.204.1",
"tailwindcss": "3.4.13",
"typescript": "^5.6.2"
"require-in-the-middle": "7.4.0",
"supabase": "^1.207.8",
"tailwindcss": "3.4.14",
"typescript": "^5.6.3"
},
"eslintConfig": {
"root": true,

View File

@@ -5,6 +5,6 @@ import baseConfig from '@kit/tailwind-config';
export default {
// We need to append the path to the UI package to the content array so that
// those classes are included correctly.
content: [...baseConfig.content],
content: [...baseConfig.content, './components/**/*.tsx', './app/**/*.tsx'],
presets: [baseConfig],
} satisfies Config;