Unify workspace dropdowns; Update layouts (#458)

Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
Giancarlo Buomprisco
2026-03-11 14:45:42 +08:00
committed by GitHub
parent ca585e09be
commit 4bc8448a1d
530 changed files with 14398 additions and 11198 deletions

View File

@@ -0,0 +1,30 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('cookiePolicy'),
};
}
async function CookiePolicyPage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t(`marketing.cookiePolicy`)}
subtitle={t(`marketing.cookiePolicyDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default CookiePolicyPage;

View File

@@ -0,0 +1,30 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('privacyPolicy'),
};
}
async function PrivacyPolicyPage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t('privacyPolicy')}
subtitle={t('privacyPolicyDescription')}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default PrivacyPolicyPage;

View File

@@ -0,0 +1,30 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('termsOfService'),
};
}
async function TermsOfServicePage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t(`marketing.termsOfService`)}
subtitle={t(`marketing.termsOfServiceDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default TermsOfServicePage;

View File

@@ -0,0 +1,58 @@
import { Footer } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
export function SiteFooter() {
return (
<Footer
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
description={<Trans i18nKey="marketing.footerDescription" />}
copyright={
<Trans
i18nKey="marketing.copyright"
values={{
product: appConfig.name,
year: new Date().getFullYear(),
}}
/>
}
sections={[
{
heading: <Trans i18nKey="marketing.about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
],
},
{
heading: <Trans i18nKey="marketing.product" />,
links: [
{
href: '/docs',
label: <Trans i18nKey="marketing.documentation" />,
},
],
},
{
heading: <Trans i18nKey="marketing.legal" />,
links: [
{
href: '/terms-of-service',
label: <Trans i18nKey="marketing.termsOfService" />,
},
{
href: '/privacy-policy',
label: <Trans i18nKey="marketing.privacyPolicy" />,
},
{
href: '/cookie-policy',
label: <Trans i18nKey="marketing.cookiePolicy" />,
},
],
},
]}
/>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { JWTUserData } from '@kit/supabase/types';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const ModeToggle = dynamic(
() =>
import('@kit/ui/mode-toggle').then((mod) => ({
default: mod.ModeToggle,
})),
{ ssr: false },
);
const MobileModeToggle = dynamic(
() =>
import('@kit/ui/mobile-mode-toggle').then((mod) => ({
default: mod.MobileModeToggle,
})),
{ ssr: false },
);
const paths = {
home: pathsConfig.app.home,
profileSettings: pathsConfig.app.personalAccountSettings,
};
const features = {
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
};
export function SiteHeaderAccountSection({
user,
}: {
user: JWTUserData | null;
}) {
const signOut = useSignOut();
if (user) {
return (
<PersonalAccountDropdown
showProfileName={false}
paths={paths}
features={features}
user={user}
signOutRequested={() => signOut.mutateAsync()}
/>
);
}
return <AuthButtons />;
}
function AuthButtons() {
return (
<div
className={'animate-in fade-in flex items-center gap-x-2 duration-500'}
>
<div className={'hidden md:flex'}>
<If condition={features.enableThemeToggle}>
<ModeToggle />
</If>
</div>
<div className={'md:hidden'}>
<If condition={features.enableThemeToggle}>
<MobileModeToggle />
</If>
</div>
<div className={'flex items-center gap-x-2'}>
<Button
nativeButton={false}
className={'hidden md:flex md:text-sm'}
render={
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth.signIn'} />
</Link>
}
variant={'outline'}
size={'sm'}
/>
<Button
nativeButton={false}
render={
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth.signUp'} />
</Link>
}
className="text-xs md:text-sm"
variant={'default'}
size={'sm'}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { JWTUserData } from '@kit/supabase/types';
import { Header } from '@kit/ui/marketing';
import { AppLogo } from '~/components/app-logo';
import { SiteHeaderAccountSection } from './site-header-account-section';
import { SiteNavigation } from './site-navigation';
export function SiteHeader(props: { user?: JWTUserData | null }) {
return (
<Header
logo={<AppLogo className="mx-auto sm:mx-0" href="/" />}
navigation={<SiteNavigation />}
actions={<SiteHeaderAccountSection user={props.user ?? null} />}
/>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { NavigationMenuItem } from '@kit/ui/navigation-menu';
import { cn, isRouteActive } from '@kit/ui/utils';
const getClassName = (path: string, currentPathName: string) => {
const isActive = isRouteActive(path, currentPathName);
return cn(
`inline-flex w-max text-sm font-medium transition-colors duration-300`,
{
'dark:text-gray-300 dark:hover:text-white': !isActive,
'text-current dark:text-white': isActive,
},
);
};
export function SiteNavigationItem({
path,
children,
}: React.PropsWithChildren<{
path: string;
}>) {
const currentPathName = usePathname();
const className = getClassName(path, currentPathName);
return (
<NavigationMenuItem key={path}>
<Link className={className} href={path} as={path} prefetch={true}>
{children}
</Link>
</NavigationMenuItem>
);
}

View File

@@ -0,0 +1,90 @@
import Link from 'next/link';
import { Menu } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { NavigationMenu, NavigationMenuList } from '@kit/ui/navigation-menu';
import { Trans } from '@kit/ui/trans';
import { SiteNavigationItem } from './site-navigation-item';
const links = {
Blog: {
label: 'marketing.blog',
path: '/blog',
},
Changelog: {
label: 'marketing.changelog',
path: '/changelog',
},
Docs: {
label: 'marketing.documentation',
path: '/docs',
},
Pricing: {
label: 'marketing.pricing',
path: '/pricing',
},
FAQ: {
label: 'marketing.faq',
path: '/faq',
},
};
export function SiteNavigation() {
const NavItems = Object.values(links).map((item) => {
return (
<SiteNavigationItem key={item.path} path={item.path}>
<Trans i18nKey={item.label} />
</SiteNavigationItem>
);
});
return (
<>
<div className={'hidden items-center justify-center md:flex'}>
<NavigationMenu>
<NavigationMenuList className={'gap-x-2.5'}>
{NavItems}
</NavigationMenuList>
</NavigationMenu>
</div>
<div className={'flex justify-start sm:items-center md:hidden'}>
<MobileDropdown />
</div>
</>
);
}
function MobileDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label={'Open Menu'}>
<Menu className={'h-8 w-8'} />
</DropdownMenuTrigger>
<DropdownMenuContent className={'w-full'}>
{Object.values(links).map((item) => {
const className = 'flex w-full h-full items-center';
return (
<DropdownMenuItem
key={item.path}
render={
<Link className={className} href={item.path}>
<Trans i18nKey={item.label} />
</Link>
}
/>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,47 @@
import { cn } from '@kit/ui/utils';
export function SitePageHeader({
title,
subtitle,
container = true,
className = '',
}: {
title: string;
subtitle: string;
container?: boolean;
className?: string;
}) {
const containerClass = container ? 'container' : '';
return (
<div
className={cn(
'border-border/40 border-b py-6 xl:py-8 2xl:py-10',
className,
)}
>
<div
className={cn(
'flex flex-col items-center gap-y-2 lg:gap-y-3',
containerClass,
)}
>
<h1
className={
'font-heading text-3xl tracking-tighter xl:text-5xl dark:text-white'
}
>
{title}
</h1>
<h2
className={
'text-muted-foreground text-lg tracking-tight 2xl:text-2xl'
}
>
{subtitle}
</h2>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createCmsClient } from '@kit/cms';
import { Post } from '../../blog/_components/post';
interface BlogPageProps {
params: Promise<{ slug: string }>;
}
const getPostBySlug = cache(postLoader);
async function postLoader(slug: string) {
const client = await createCmsClient();
return client.getContentItemBySlug({ slug, collection: 'posts' });
}
export async function generateMetadata({
params,
}: BlogPageProps): Promise<Metadata> {
const slug = (await params).slug;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
const { title, publishedAt, description, image } = post;
return Promise.resolve({
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime: publishedAt,
url: post.url,
images: image
? [
{
url: image,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : [],
},
});
}
async function BlogPost({ params }: BlogPageProps) {
const slug = (await params).slug;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
return (
<div className={'container sm:max-w-none sm:p-0'}>
<Post post={post} content={post.content} />
</div>
);
}
export default BlogPost;

View File

@@ -0,0 +1,58 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
export function BlogPagination(props: {
currentPage: number;
canGoToNextPage: boolean;
canGoToPreviousPage: boolean;
}) {
const navigate = useGoToPage();
return (
<div className={'flex items-center space-x-2'}>
<If condition={props.canGoToPreviousPage}>
<Button
variant={'outline'}
onClick={() => {
navigate(props.currentPage - 1);
}}
>
<ArrowLeft className={'mr-2 h-4'} />
<Trans i18nKey={'marketing.blogPaginationPrevious'} />
</Button>
</If>
<If condition={props.canGoToNextPage}>
<Button
variant={'outline'}
onClick={() => {
navigate(props.currentPage + 1);
}}
>
<Trans i18nKey={'marketing.blogPaginationNext'} />
<ArrowRight className={'ml-2 h-4'} />
</Button>
</If>
</div>
);
}
function useGoToPage() {
const router = useRouter();
const path = usePathname();
return (page: number) => {
const searchParams = new URLSearchParams({
page: page.toString(),
});
router.push(path + '?' + searchParams.toString());
};
}

View File

@@ -0,0 +1,24 @@
import Image from 'next/image';
import { cn } from '@kit/ui/utils';
type Props = {
title: string;
src: string;
preloadImage?: boolean;
className?: string;
};
export function CoverImage({ title, src, preloadImage, className }: Props) {
return (
<Image
className={cn('block rounded-md object-cover', {
className,
})}
src={src}
priority={preloadImage}
alt={`Cover Image for ${title}`}
fill
/>
);
}

View 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>;
};

View File

@@ -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>
);
}

View File

@@ -0,0 +1,50 @@
import { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import { CoverImage } from './cover-image';
import { DateFormatter } from './date-formatter';
export function PostHeader({ post }: { post: Cms.ContentItem }) {
const { title, publishedAt, description, image } = post;
return (
<div className={'flex flex-1 flex-col'}>
<div className={cn('border-border/50 border-b py-8')}>
<div className={'mx-auto flex max-w-3xl flex-col gap-y-2.5'}>
<div>
<span className={'text-muted-foreground text-xs'}>
<DateFormatter dateString={publishedAt} />
</span>
</div>
<h1
className={
'font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white'
}
>
{title}
</h1>
<h2
className={'text-muted-foreground text-base'}
dangerouslySetInnerHTML={{ __html: description ?? '' }}
></h2>
</div>
</div>
<If condition={image}>
{(imageUrl) => (
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
<CoverImage
preloadImage
className="rounded-md"
title={title}
src={imageUrl}
/>
</div>
)}
</If>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Link from 'next/link';
import { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
type Props = {
post: Cms.ContentItem;
preloadImage?: boolean;
imageHeight?: string | number;
};
const DEFAULT_IMAGE_HEIGHT = 220;
export function PostPreview({
post,
preloadImage,
imageHeight,
}: React.PropsWithChildren<Props>) {
const { title, image, publishedAt, description } = post;
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
const slug = `/blog/${post.slug}`;
return (
<Link
href={slug}
className="hover:bg-muted/50 active:bg-muted flex flex-col gap-y-2.5 rounded-md p-4 transition-all"
>
<If condition={image}>
{(imageUrl) => (
<div className="relative mb-2 w-full" style={{ height }}>
<CoverImage
preloadImage={preloadImage}
title={title}
src={imageUrl}
/>
</div>
)}
</If>
<div className={'flex flex-col space-y-2'}>
<div className={'flex flex-col space-y-2'}>
<div className="flex flex-row items-center gap-x-3 text-xs">
<div className="text-muted-foreground">
<DateFormatter dateString={publishedAt} />
</div>
</div>
<h2 className="text-lg leading-snug font-medium tracking-tight">
<span className="hover:underline">{title}</span>
</h2>
</div>
<p
className="text-muted-foreground mb-4 text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: trimText(description ?? '', 200) }}
/>
</div>
</Link>
);
}
function trimText(text: string, maxLength: number) {
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
}

View File

@@ -0,0 +1,24 @@
import type { Cms } from '@kit/cms';
import { ContentRenderer } from '@kit/cms';
import { PostHeader } from './post-header';
export function Post({
post,
content,
}: {
post: Cms.ContentItem;
content: unknown;
}) {
return (
<div>
<PostHeader post={post} />
<div className={'mx-auto flex max-w-3xl flex-col space-y-6 py-8'}>
<article className="markdoc">
<ContentRenderer content={content} />
</article>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { getLocale, getTranslations } from 'next-intl/server';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
// local imports
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 }>;
}
const BLOG_POSTS_PER_PAGE = 10;
export const generateMetadata = async (
props: BlogPageProps,
): Promise<Metadata> => {
const t = await getTranslations('marketing');
const resolvedLanguage = await getLocale();
const searchParams = await props.searchParams;
const limit = BLOG_POSTS_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total } = await getContentItems(resolvedLanguage, limit, offset);
return {
title: t('blog'),
description: t('blogSubtitle'),
pagination: {
previous: page > 0 ? `/blog?page=${page - 1}` : undefined,
next: offset + limit < total ? `/blog?page=${page + 1}` : undefined,
},
};
};
const getContentItems = cache(
async (language: string | undefined, limit: number, offset: number) => {
const client = await createCmsClient();
const logger = await getLogger();
try {
return await client.getContentItems({
collection: 'posts',
limit,
offset,
language,
content: false,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
} catch (error) {
logger.error({ error }, 'Failed to load blog posts');
return { total: 0, items: [] };
}
},
);
async function BlogPage(props: BlogPageProps) {
const t = await getTranslations('marketing');
const language = await getLocale();
const searchParams = await props.searchParams;
const limit = BLOG_POSTS_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total, items: posts } = await getContentItems(
language,
limit,
offset,
);
return (
<>
<SitePageHeader title={t('blog')} subtitle={t('blogSubtitle')} />
<div className={'container flex flex-col space-y-6 py-8'}>
<If
condition={posts.length > 0}
fallback={<Trans i18nKey="marketing.noPosts" />}
>
<PostsGridList>
{posts.map((post, idx) => {
return <PostPreview key={idx} post={post} />;
})}
</PostsGridList>
<div>
<BlogPagination
currentPage={page}
canGoToNextPage={offset + limit < total}
canGoToPreviousPage={page > 0}
/>
</div>
</If>
</div>
</>
);
}
export default BlogPage;
function PostsGridList({ children }: React.PropsWithChildren) {
return (
<div className="grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-2 md:gap-y-12 lg:grid-cols-3">
{children}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createCmsClient } from '@kit/cms';
import { ChangelogDetail } from '../_components/changelog-detail';
interface ChangelogEntryPageProps {
params: Promise<{ slug: string }>;
}
const getChangelogData = cache(changelogEntryLoader);
async function changelogEntryLoader(slug: string) {
const client = await createCmsClient();
const [entry, allEntries] = await Promise.all([
client.getContentItemBySlug({ slug, collection: 'changelog' }),
client.getContentItems({
collection: 'changelog',
sortBy: 'publishedAt',
sortDirection: 'desc',
content: false,
}),
]);
if (!entry) {
return null;
}
// Find previous and next entries in the timeline
const currentIndex = allEntries.items.findIndex((item) => item.slug === slug);
const newerEntry =
currentIndex > 0 ? allEntries.items[currentIndex - 1] : null;
const olderEntry =
currentIndex < allEntries.items.length - 1
? allEntries.items[currentIndex + 1]
: null;
return {
entry,
previousEntry: olderEntry,
nextEntry: newerEntry,
};
}
export async function generateMetadata({
params,
}: ChangelogEntryPageProps): Promise<Metadata> {
const slug = (await params).slug;
const data = await getChangelogData(slug);
if (!data) {
notFound();
}
const { title, publishedAt, description, image } = data.entry;
return Promise.resolve({
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime: publishedAt,
url: data.entry.url,
images: image
? [
{
url: image,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : [],
},
});
}
async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) {
const slug = (await params).slug;
const data = await getChangelogData(slug);
if (!data) {
notFound();
}
return (
<div className="container sm:max-w-none sm:p-0">
<ChangelogDetail
entry={data.entry}
content={data.entry.content}
previousEntry={data.previousEntry ?? null}
nextEntry={data.nextEntry ?? null}
/>
</div>
);
}
export default ChangelogEntryPage;

View File

@@ -0,0 +1,36 @@
import type { Cms } from '@kit/cms';
import { ContentRenderer } from '@kit/cms';
import { ChangelogHeader } from './changelog-header';
import { ChangelogNavigation } from './changelog-navigation';
interface ChangelogDetailProps {
entry: Cms.ContentItem;
content: unknown;
previousEntry: Cms.ContentItem | null;
nextEntry: Cms.ContentItem | null;
}
export function ChangelogDetail({
entry,
content,
previousEntry,
nextEntry,
}: ChangelogDetailProps) {
return (
<div>
<ChangelogHeader entry={entry} />
<div className="mx-auto flex max-w-3xl flex-col space-y-6 py-8">
<article className="markdoc">
<ContentRenderer content={content} />
</article>
</div>
<ChangelogNavigation
previousEntry={previousEntry}
nextEntry={nextEntry}
/>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import Link from 'next/link';
import { type Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import { DateBadge } from './date-badge';
interface ChangelogEntryProps {
entry: Cms.ContentItem;
highlight?: boolean;
}
export function ChangelogEntry({
entry,
highlight = false,
}: ChangelogEntryProps) {
const { title, slug, publishedAt, description } = entry;
const entryUrl = `/changelog/${slug}`;
return (
<div className="flex gap-6 md:gap-8">
<div className="md:border-border relative flex flex-1 flex-col space-y-0 gap-y-2.5 border-l border-dashed border-transparent pb-4 md:pl-8 lg:pl-12">
{highlight ? (
<span className="absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 md:flex">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-red-400"></span>
</span>
) : (
<div
className={cn(
'bg-muted absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 rounded-full md:block',
)}
/>
)}
<div className="hover:bg-muted/50 active:bg-muted rounded-md transition-colors">
<Link href={entryUrl} className="block space-y-2 p-4">
<div>
<DateBadge date={publishedAt} />
</div>
<h3 className="text-xl leading-tight font-semibold tracking-tight group-hover/link:underline">
{title}
</h3>
<If condition={description}>
{(desc) => (
<p className="text-muted-foreground text-sm leading-relaxed">
{desc}
</p>
)}
</If>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { CoverImage } from '../../blog/_components/cover-image';
import { DateFormatter } from '../../blog/_components/date-formatter';
export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) {
const { title, publishedAt, description, image } = entry;
return (
<div className="flex flex-1 flex-col">
<div className="border-border/50 border-b py-4">
<div className="mx-auto flex max-w-3xl items-center justify-between">
<Link
href="/changelog"
className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<Trans i18nKey="marketing.changelog" />
</Link>
</div>
</div>
<div className={cn('border-border/50 border-b py-8')}>
<div className="mx-auto flex max-w-3xl flex-col gap-y-2.5">
<div>
<span className="text-muted-foreground text-xs">
<DateFormatter dateString={publishedAt} />
</span>
</div>
<h1 className="font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white">
{title}
</h1>
{description && (
<h2
className="text-muted-foreground text-base"
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
</div>
</div>
<If condition={image}>
{(imageUrl) => (
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
<CoverImage
preloadImage
className="rounded-md"
title={title}
src={imageUrl}
/>
</div>
)}
</If>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { DateFormatter } from '../../blog/_components/date-formatter';
interface ChangelogNavigationProps {
previousEntry: Cms.ContentItem | null;
nextEntry: Cms.ContentItem | null;
}
interface NavLinkProps {
entry: Cms.ContentItem;
direction: 'previous' | 'next';
}
function NavLink({ entry, direction }: NavLinkProps) {
const isPrevious = direction === 'previous';
const Icon = isPrevious ? ChevronLeft : ChevronRight;
const i18nKey = isPrevious
? 'marketing.changelogNavigationPrevious'
: 'marketing.changelogNavigationNext';
return (
<Link
href={`/changelog/${entry.slug}`}
className={cn(
'border-border/50 hover:bg-muted/50 group flex flex-col gap-2 rounded-lg border p-4 transition-all',
!isPrevious && 'text-right md:items-end',
)}
>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
{isPrevious && <Icon className="h-3 w-3" />}
<span className="font-medium tracking-wider uppercase">
<Trans i18nKey={i18nKey} />
</span>
{!isPrevious && <Icon className="h-3 w-3" />}
</div>
<div className="space-y-1">
<h3 className="group-hover:text-primary text-sm leading-tight font-semibold transition-colors">
{entry.title}
</h3>
<div className="text-muted-foreground text-xs">
<DateFormatter dateString={entry.publishedAt} />
</div>
</div>
</Link>
);
}
export function ChangelogNavigation({
previousEntry,
nextEntry,
}: ChangelogNavigationProps) {
return (
<div className="border-border/50 border-t py-8">
<div className="mx-auto max-w-3xl">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<If condition={previousEntry} fallback={<div />}>
{(prev) => <NavLink entry={prev} direction="previous" />}
</If>
<If condition={nextEntry} fallback={<div />}>
{(next) => <NavLink entry={next} direction="next" />}
</If>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
interface ChangelogPaginationProps {
currentPage: number;
canGoToNextPage: boolean;
canGoToPreviousPage: boolean;
}
export function ChangelogPagination({
currentPage,
canGoToNextPage,
canGoToPreviousPage,
}: ChangelogPaginationProps) {
const nextPage = currentPage + 1;
const previousPage = currentPage - 1;
return (
<div className="flex justify-end gap-2">
{canGoToPreviousPage && (
<Button
render={<Link href={`/changelog?page=${previousPage}`} />}
variant="outline"
size="sm"
>
<ArrowLeft className="mr-2 h-3 w-3" />
<span>
<Trans i18nKey="marketing.changelogPaginationPrevious" />
</span>
</Button>
)}
{canGoToNextPage && (
<Button
render={<Link href={`/changelog?page=${nextPage}`} />}
variant="outline"
size="sm"
>
<span>
<Trans i18nKey="marketing.changelogPaginationNext" />
</span>
<ArrowRight className="ml-2 h-3 w-3" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
interface DateBadgeProps {
date: string;
}
export function DateBadge({ date }: DateBadgeProps) {
const formattedDate = format(new Date(date), 'MMMM d, yyyy');
return (
<div className="text-muted-foreground flex flex-shrink-0 items-center gap-2 text-sm">
<CalendarIcon className="size-3" />
<span>{formattedDate}</span>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { getLocale, getTranslations } from 'next-intl/server';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { SitePageHeader } from '../_components/site-page-header';
import { ChangelogEntry } from './_components/changelog-entry';
import { ChangelogPagination } from './_components/changelog-pagination';
interface ChangelogPageProps {
searchParams: Promise<{ page?: string }>;
}
const CHANGELOG_ENTRIES_PER_PAGE = 50;
export const generateMetadata = async (
props: ChangelogPageProps,
): Promise<Metadata> => {
const t = await getTranslations('marketing');
const resolvedLanguage = await getLocale();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total } = await getContentItems(resolvedLanguage, limit, offset);
return {
title: t('changelog'),
description: t('changelogSubtitle'),
pagination: {
previous: page > 0 ? `/changelog?page=${page - 1}` : undefined,
next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined,
},
};
};
const getContentItems = cache(
async (language: string | undefined, limit: number, offset: number) => {
const client = await createCmsClient();
const logger = await getLogger();
try {
return await client.getContentItems({
collection: 'changelog',
limit,
offset,
content: false,
language,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
} catch (error) {
logger.error({ error }, 'Failed to load changelog entries');
return { total: 0, items: [] };
}
},
);
async function ChangelogPage(props: ChangelogPageProps) {
const t = await getTranslations('marketing');
const language = await getLocale();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total, items: entries } = await getContentItems(
language,
limit,
offset,
);
return (
<>
<SitePageHeader
title={t('changelog')}
subtitle={t('changelogSubtitle')}
/>
<div className="container flex max-w-4xl flex-col space-y-12 py-12">
<If
condition={entries.length > 0}
fallback={<Trans i18nKey="marketing.noChangelogEntries" />}
>
<div className="space-y-0">
{entries.map((entry, index) => {
return (
<ChangelogEntry
key={entry.id}
entry={entry}
highlight={index === 0}
/>
);
})}
</div>
<ChangelogPagination
currentPage={page}
canGoToNextPage={offset + limit < total}
canGoToPreviousPage={page > 0}
/>
</If>
</div>
</>
);
}
export default ChangelogPage;

View File

@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans';
import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.schema';
import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions';
export function ContactForm() {
const [state, setState] = useState({
success: false,
error: false,
});
const { execute, isPending } = useAction(sendContactEmail, {
onSuccess: () => {
setState({ success: true, error: false });
},
onError: () => {
setState({ error: true, success: false });
},
});
const form = useForm({
resolver: zodResolver(ContactEmailSchema),
defaultValues: {
name: '',
email: '',
message: '',
},
});
if (state.success) {
return <SuccessAlert />;
}
if (state.error) {
return <ErrorAlert />;
}
return (
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
execute(data);
})}
>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactName'} />
</FormLabel>
<FormControl>
<Input maxLength={200} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'email'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactEmail'} />
</FormLabel>
<FormControl>
<Input type={'email'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'message'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactMessage'} />
</FormLabel>
<FormControl>
<Textarea
className={'min-h-36'}
maxLength={5000}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<Button disabled={isPending} type={'submit'}>
<Trans i18nKey={'marketing.sendMessage'} />
</Button>
</form>
</Form>
);
}
function SuccessAlert() {
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'marketing.contactSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'marketing.contactSuccessDescription'} />
</AlertDescription>
</Alert>
);
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'marketing.contactError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'marketing.contactErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,7 @@
import * as z from 'zod';
export const ContactEmailSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
message: z.string().min(1).max(5000),
});

View File

@@ -0,0 +1,45 @@
'use server';
import * as z from 'zod';
import { getMailer } from '@kit/mailers';
import { publicActionClient } from '@kit/next/safe-action';
import { ContactEmailSchema } from '../contact-email.schema';
const contactEmail = z
.string({
error:
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
})
.parse(process.env.CONTACT_EMAIL);
const emailFrom = z
.string({
error:
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
})
.parse(process.env.EMAIL_SENDER);
export const sendContactEmail = publicActionClient
.schema(ContactEmailSchema)
.action(async ({ parsedInput: data }) => {
const mailer = await getMailer();
await mailer.sendEmail({
to: contactEmail,
from: emailFrom,
subject: 'Contact Form Submission',
html: `
<p>
You have received a new contact form submission.
</p>
<p>Name: ${data.name}</p>
<p>Email: ${data.email}</p>
<p>Message: ${data.message}</p>
`,
});
return {};
});

View File

@@ -0,0 +1,51 @@
import { getTranslations } from 'next-intl/server';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('contact'),
};
}
async function ContactPage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader title={t(`contact`)} subtitle={t(`contactDescription`)} />
<div className={'container mx-auto'}>
<div
className={'flex flex-1 flex-col items-center justify-center py-8'}
>
<div
className={
'flex w-full max-w-lg flex-col space-y-4 rounded-lg border p-8'
}
>
<div>
<Heading level={3}>
<Trans i18nKey={'marketing.contactHeading'} />
</Heading>
<p className={'text-muted-foreground'}>
<Trans i18nKey={'marketing.contactSubheading'} />
</p>
</div>
<ContactForm />
</div>
</div>
</div>
</div>
);
}
export default ContactPage;

View File

@@ -0,0 +1,92 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { ContentRenderer, createCmsClient } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { cn } from '@kit/ui/utils';
// local imports
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' });
}
export const generateMetadata = async ({ params }: DocumentationPageProps) => {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const { title, description } = page;
return {
title,
description,
};
};
async function DocumentationPage({ params }: DocumentationPageProps) {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const description = page?.description ?? '';
return (
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden'}>
<div className={'flex size-full overflow-y-hidden'}>
<div className="relative size-full">
<article
className={cn(
'absolute size-full w-full gap-y-12 overflow-y-auto pt-4 pb-36',
)}
>
<section
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
>
<h1
className={
'text-foreground text-3xl font-semibold tracking-tighter'
}
>
{page.title}
</h1>
<h2 className={'text-secondary-foreground/80 text-lg'}>
{description}
</h2>
</section>
<div className={'markdoc'}>
<ContentRenderer content={page.content} />
</div>
</article>
</div>
</div>
<If condition={page.children.length > 0}>
<Separator />
<DocsCards cards={page.children ?? []} />
</If>
</div>
);
}
export default DocumentationPage;

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
export function DocsCard({
title,
subtitle,
children,
link,
}: React.PropsWithChildren<{
title: string;
subtitle?: string | null;
link: { url: string; label?: string };
}>) {
return (
<Link href={link.url} className="flex flex-col">
<div
className={`hover:bg-muted/70 flex grow flex-col gap-y-0.5 rounded border p-4`}
>
<h3 className="mt-0 text-lg font-medium hover:underline dark:text-white">
{title}
</h3>
{subtitle && (
<div className="text-muted-foreground text-sm">
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
</div>
)}
{children && <div className="text-sm">{children}</div>}
</div>
</Link>
);
}

View File

@@ -0,0 +1,24 @@
import { Cms } from '@kit/cms';
import { DocsCard } from './docs-card';
export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
return (
<div className={'absolute flex w-full flex-col gap-4 pb-48 lg:max-w-2xl'}>
{cardsSortedByOrder.map((item) => {
return (
<DocsCard
key={item.title}
title={item.title}
subtitle={item.description}
link={{
url: `/docs/${item.slug}`,
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavLink({
label,
url,
children,
}: React.PropsWithChildren<{ label: string; url: string }>) {
const currentPath = usePathname();
const isCurrent = isRouteActive(url, currentPath);
return (
<SidebarMenuItem>
<SidebarMenuButton
render={<Link href={url} />}
isActive={isCurrent}
className={cn('text-secondary-foreground transition-all')}
>
<span className="block max-w-full truncate">{label}</span>
{children}
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { usePathname } from 'next/navigation';
import { Cms } from '@kit/cms';
import { Collapsible } from '@kit/ui/collapsible';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavigationCollapsible(
props: React.PropsWithChildren<{
node: Cms.ContentItem;
prefix: string;
}>,
) {
const currentPath = usePathname();
const prefix = props.prefix;
const isChildActive = props.node.children.some((child) =>
isRouteActive(prefix + '/' + child.url, currentPath),
);
return (
<Collapsible
className={cn('group/collapsible', {
'group/active': isChildActive,
})}
defaultOpen={isChildActive ? true : !props.node.collapsed}
>
{props.children}
</Collapsible>
);
}

View File

@@ -0,0 +1,153 @@
import { ChevronDown } from 'lucide-react';
import { Cms } from '@kit/cms';
import { CollapsibleContent, CollapsibleTrigger } from '@kit/ui/collapsible';
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@kit/ui/sidebar';
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button';
function Node({
node,
level,
prefix,
}: {
node: Cms.ContentItem;
level: number;
prefix: string;
}) {
const url = `${prefix}/${node.slug}`;
const label = node.label ? node.label : node.title;
return (
<NodeContainer node={node} prefix={prefix}>
<NodeTrigger node={node} label={label} url={url} />
<NodeContentContainer node={node}>
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
</NodeContentContainer>
</NodeContainer>
);
}
function NodeContentContainer({
node,
children,
}: {
node: Cms.ContentItem;
children: React.ReactNode;
}) {
if (node.collapsible) {
return <CollapsibleContent>{children}</CollapsibleContent>;
}
return children;
}
function NodeContainer({
node,
prefix,
children,
}: {
node: Cms.ContentItem;
prefix: string;
children: React.ReactNode;
}) {
if (node.collapsible) {
return (
<DocsNavigationCollapsible node={node} prefix={prefix}>
{children}
</DocsNavigationCollapsible>
);
}
return children;
}
function NodeTrigger({
node,
label,
url,
}: {
node: Cms.ContentItem;
label: string;
url: string;
}) {
if (node.collapsible) {
return (
<CollapsibleTrigger render={<SidebarMenuItem />}>
<SidebarMenuButton>
{label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
);
}
return <DocsNavLink label={label} url={url} />;
}
function Tree({
pages,
level,
prefix,
}: {
pages: Cms.ContentItem[];
level: number;
prefix: string;
}) {
if (level === 0) {
return pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
));
}
if (pages.length === 0) {
return null;
}
return (
<SidebarMenuSub>
{pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
))}
</SidebarMenuSub>
);
}
export function DocsNavigation({
pages,
prefix = '/docs',
}: {
pages: Cms.ContentItem[];
prefix?: string;
}) {
return (
<>
<Sidebar
variant={'sidebar'}
className={'sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'}
>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className={'pb-48'}>
<Tree pages={pages} level={0} prefix={prefix} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Sidebar>
<FloatingDocumentationNavigationButton />
</>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { Menu } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { useSidebar } from '@kit/ui/sidebar';
export function FloatingDocumentationNavigationButton() {
const { toggleSidebar } = useSidebar();
return (
<Button
size="custom"
variant="custom"
className={
'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden'
}
onClick={toggleSidebar}
>
<Menu className={'text-primary-foreground size-6'} />
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import { cache } from 'react';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
/**
* @name getDocs
* @description Load the documentation pages.
* @param language
*/
export const getDocs = cache(docsLoader);
async function docsLoader(language: string | undefined) {
const cms = await createCmsClient();
const logger = await getLogger();
try {
const data = await cms.getContentItems({
collection: 'documentation',
language,
limit: Infinity,
content: false,
});
return data.items;
} catch (error) {
logger.error({ error }, 'Failed to load documentation pages');
return [];
}
}

View File

@@ -0,0 +1,33 @@
import { Cms } from '@kit/cms';
/**
* @name buildDocumentationTree
* @description Build a tree structure for the documentation pages.
* @param pages
*/
export function buildDocumentationTree(pages: Cms.ContentItem[]) {
const tree: Cms.ContentItem[] = [];
pages.forEach((page) => {
if (page.parentId) {
const parent = pages.find((item) => item.slug === page.parentId);
if (!parent) {
return;
}
if (!parent.children) {
parent.children = [];
}
parent.children.push(page);
// sort children by order
parent.children.sort((a, b) => a.order - b.order);
} else {
tree.push(page);
}
});
return tree.sort((a, b) => a.order - b.order);
}

View File

@@ -0,0 +1,45 @@
import { getLocale } from 'next-intl/server';
import { SidebarProvider } from '@kit/ui/sidebar';
// local imports
import { DocsNavigation } from './_components/docs-navigation';
import { getDocs } from './_lib/server/docs.loader';
import { buildDocumentationTree } from './_lib/utils';
async function DocsLayout({ children }: React.PropsWithChildren) {
const locale = await getLocale();
const docs = await getDocs(locale);
const tree = buildDocumentationTree(docs);
return (
<div className={'container h-[calc(100vh-56px)] overflow-y-hidden'}>
<SidebarProvider
className="lg:gap-x-6"
style={{ '--sidebar-width': '17em' } as React.CSSProperties}
>
<HideFooterStyles />
<DocsNavigation pages={tree} />
{children}
</SidebarProvider>
</div>
);
}
function HideFooterStyles() {
return (
<style
dangerouslySetInnerHTML={{
__html: `
.site-footer {
display: none;
}
`,
}}
/>
);
}
export default DocsLayout;

View File

@@ -0,0 +1,37 @@
import { getLocale, getTranslations } from 'next-intl/server';
import { SitePageHeader } from '../_components/site-page-header';
import { DocsCards } from './_components/docs-cards';
import { getDocs } from './_lib/server/docs.loader';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
return {
title: t('documentation'),
};
};
async function DocsPage() {
const t = await getTranslations('marketing');
const locale = await getLocale();
const items = await getDocs(locale);
// Filter out any docs that have a parentId, as these are children of other docs
const cards = items.filter((item) => !item.parentId);
return (
<div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
<SitePageHeader
title={t('documentation')}
subtitle={t('documentationSubtitle')}
/>
<div className={'relative flex size-full justify-center overflow-y-auto'}>
<DocsCards cards={cards} />
</div>
</div>
);
}
export default DocsPage;

View File

@@ -0,0 +1,139 @@
import Link from 'next/link';
import { ArrowRight, ChevronDown } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
return {
title: t('faq'),
};
};
async function FAQPage() {
const t = await getTranslations('marketing');
// replace this content with translations
const faqItems = [
{
// or: t('faq.question1')
question: `Do you offer a free trial?`,
// or: t('faq.answer1')
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 structuredData = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems.map((item) => {
return {
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
};
}),
};
return (
<>
<script
key={'ld:json'}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className={'flex flex-col space-y-4 xl:space-y-8'}>
<SitePageHeader title={t('faq')} subtitle={t('faqSubtitle')} />
<div className={'container flex flex-col items-center space-y-8 pb-16'}>
<div className="divide-border flex w-full max-w-xl flex-col divide-y divide-dashed rounded-md border">
{faqItems.map((item, index) => {
return <FaqItem key={index} item={item} />;
})}
</div>
<div>
<Button
nativeButton={false}
render={<Link href={'/contact'} />}
variant={'link'}
>
<span>
<Trans i18nKey={'marketing.contactFaq'} />
</span>
<ArrowRight className={'ml-2 w-4'} />
</Button>
</div>
</div>
</div>
</>
);
}
export default FAQPage;
function FaqItem({
item,
}: React.PropsWithChildren<{
item: {
question: string;
answer: string;
};
}>) {
return (
<details
className={
'hover:bg-muted/70 [&:open]:bg-muted/70 [&:open]:hover:bg-muted transition-all'
}
>
<summary
className={'flex items-center justify-between p-4 hover:cursor-pointer'}
>
<h2 className={'cursor-pointer font-sans text-base'}>
<Trans i18nKey={item.question} defaults={item.question} />
</h2>
<div>
<ChevronDown
className={'h-5 transition duration-300 group-open:-rotate-180'}
/>
</div>
</summary>
<div className={'text-muted-foreground flex flex-col gap-y-2 px-4 pb-2'}>
<Trans i18nKey={item.answer} defaults={item.answer} />
</div>
</details>
);
}

View File

@@ -0,0 +1,22 @@
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header';
async function SiteLayout(props: React.PropsWithChildren) {
const client = getSupabaseServerClient();
const user = await requireUser(client, { verifyMfa: false });
return (
<div className={'flex min-h-[100vh] flex-col'}>
<SiteHeader user={user.data} />
{props.children}
<SiteFooter />
</div>
);
}
export default SiteLayout;

View File

@@ -0,0 +1,203 @@
import Image from 'next/image';
import Link from 'next/link';
import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
import { PricingTable } from '@kit/billing-gateway/marketing';
import {
CtaButton,
EcosystemShowcase,
FeatureCard,
FeatureGrid,
FeatureShowcase,
FeatureShowcaseIconContainer,
Hero,
Pill,
PillActionButton,
SecondaryHero,
} from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
function Home() {
return (
<div className={'mt-4 flex flex-col space-y-24 py-14'}>
<div className={'mx-auto'}>
<Hero
pill={
<Pill label={'New'}>
<span>The SaaS Starter Kit for ambitious developers</span>
<PillActionButton
render={
<Link href={'/auth/sign-up'}>
<ArrowRightIcon className={'h-4 w-4'} />
</Link>
}
/>
</Pill>
}
title={
<span className="text-secondary-foreground">
<span>Ship a SaaS faster than ever.</span>
</span>
}
subtitle={
<span>
Makerkit gives you a production-ready boilerplate to build your
SaaS faster than ever before with the next-gen SaaS Starter Kit.
Get started in minutes.
</span>
}
cta={<MainCallToActionButton />}
image={
<Image
priority
className={
'dark:border-primary/10 w-full rounded-lg border border-gray-200'
}
width={3558}
height={2222}
src={`/images/dashboard.webp`}
alt={`App Image`}
/>
}
/>
</div>
<div className={'container mx-auto'}>
<div className={'py-4 xl:py-8'}>
<FeatureShowcase
heading={
<>
<b className="font-medium tracking-tight dark:text-white">
The ultimate SaaS Starter Kit
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
Unleash your creativity and build your SaaS faster than ever
with Makerkit.
</span>
</>
}
icon={
<FeatureShowcaseIconContainer>
<LayoutDashboard className="h-4 w-4" />
<span>All-in-one solution</span>
</FeatureShowcaseIconContainer>
}
>
<FeatureGrid>
<FeatureCard
className={'relative col-span-1 overflow-hidden'}
label={'Beautiful Dashboard'}
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
></FeatureCard>
<FeatureCard
className={'relative col-span-1 w-full overflow-hidden'}
label={'Authentication'}
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
></FeatureCard>
<FeatureCard
className={'relative col-span-1 overflow-hidden'}
label={'Multi Tenancy'}
description={`Multi tenant memberships for your SaaS business.`}
/>
<FeatureCard
className={'relative col-span-1 overflow-hidden'}
label={'Billing'}
description={`Makerkit supports multiple payment gateways to charge your customers.`}
/>
<FeatureCard
className={'relative col-span-1 overflow-hidden'}
label={'Plugins'}
description={`Extend your SaaS with plugins that you can install using the CLI.`}
/>
<FeatureCard
className={'relative col-span-1 overflow-hidden'}
label={'Documentation'}
description={`Makerkit provides a comprehensive documentation to help you get started.`}
/>
</FeatureGrid>
</FeatureShowcase>
</div>
</div>
<div className={'container mx-auto'}>
<EcosystemShowcase
heading="The ultimate SaaS Starter Kit for founders."
description="Unleash your creativity and build your SaaS faster than ever with Makerkit. Get started in minutes and ship your SaaS in no time."
>
<Image
className="rounded-md"
src={'/images/sign-in.webp'}
alt="Sign in"
width={1000}
height={1000}
/>
</EcosystemShowcase>
</div>
<div className={'container mx-auto'}>
<div
className={
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
}
>
<SecondaryHero
pill={<Pill label="Start for free">No credit card required.</Pill>}
heading="Fair pricing for all types of businesses"
subheading="Get started on our free plan and upgrade when you are ready."
/>
<div className={'w-full'}>
<PricingTable
config={billingConfig}
paths={{
signUp: pathsConfig.auth.signUp,
return: pathsConfig.app.home,
}}
/>
</div>
</div>
</div>
</div>
);
}
export default Home;
function MainCallToActionButton() {
return (
<div className={'flex space-x-2.5'}>
<CtaButton className="h-10 text-sm">
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common.getStarted'} />
</span>
<ArrowRightIcon
className={
'animate-in fade-in slide-in-from-left-8 h-4' +
' zoom-in fill-mode-both delay-1000 duration-1000'
}
/>
</span>
</Link>
</CtaButton>
<CtaButton variant={'link'} className="h-10 text-sm">
<Link href={'/pricing'}>
<Trans i18nKey={'common.pricing'} />
</Link>
</CtaButton>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { getTranslations } from 'next-intl/server';
import { PricingTable } from '@kit/billing-gateway/marketing';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
return {
title: t('pricing'),
};
};
const paths = {
signUp: pathsConfig.auth.signUp,
return: pathsConfig.app.home,
};
async function PricingPage() {
const t = await getTranslations('marketing');
return (
<div className={'flex flex-col space-y-8'}>
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
<div className={'container mx-auto pb-8 xl:pb-16'}>
<PricingTable paths={paths} config={billingConfig} />
</div>
</div>
);
}
export default PricingPage;

View File

@@ -0,0 +1,56 @@
# Super Admin
## Critical Security Rules
- **ALWAYS** use `AdminGuard` to protect pages
- **ALWAYS** validate admin status before operations
- **NEVER** bypass authentication or authorization
- **ALWAYS** audit admin operations with logging
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
## Page Structure
```typescript
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page';
async function AdminPage() {
return (
<>
<PageHeader title="Admin" />
<PageBody>{/* Content */}</PageBody>
</>
);
}
export default AdminGuard(AdminPage);
```
## Admin Client Usage
```typescript
import { isSuperAdmin } from '@kit/admin';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function adminOperation() {
// CRITICAL: Validate first - admin client bypasses RLS
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized');
}
const adminClient = getSupabaseServerAdminClient();
// Safe to proceed
}
```
## Audit Logging
```typescript
const logger = await getLogger();
logger.info({
name: 'admin-audit',
action: 'delete-user',
adminId: currentUser.id,
targetId: userId,
}, 'Admin action performed');
```

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Users } from 'lucide-react';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
} from '@kit/ui/sidebar';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
export function AdminSidebar() {
const path = usePathname();
return (
<Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}>
<AppLogo href={'/admin'} className="max-w-full" />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton
isActive={path === '/admin'}
render={<Link className={'flex gap-2.5'} href={'/admin'} />}
>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path.includes('/admin/accounts')}
render={
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,30 @@
import Link from 'next/link';
import { Menu } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
export function AdminMobileNavigation() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-8 w-8'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={'/admin'}>Home</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={'/admin/accounts'}>Accounts</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,47 @@
import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
interface Params {
params: Promise<{
id: string;
}>;
}
export const generateMetadata = async (props: Params) => {
const params = await props.params;
const account = await loadAccount(params.id);
return {
title: `Admin | ${account.name}`,
};
};
async function AccountPage(props: Params) {
const params = await props.params;
const account = await loadAccount(params.id);
return <AdminAccountPage account={account} />;
}
export default AdminGuard(AccountPage);
const loadAccount = cache(accountLoader);
async function accountLoader(id: string) {
const client = getSupabaseServerClient();
const { data, error } = await client
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single();
if (error) {
throw error;
}
return data;
}

View File

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

View File

@@ -0,0 +1,79 @@
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
interface SearchParams {
page?: string;
account_type?: 'all' | 'team' | 'personal';
query?: string;
}
interface AdminAccountsPageProps {
searchParams: Promise<SearchParams>;
}
export const metadata = {
title: `Accounts`,
};
async function AccountsPage(props: AdminAccountsPageProps) {
const client = getSupabaseServerClient();
const searchParams = await props.searchParams;
const page = searchParams.page ? parseInt(searchParams.page) : 1;
return (
<>
<PageHeader description={<AppBreadcrumbs />}>
<div className="flex justify-end">
<AdminCreateUserDialog>
<Button data-test="admin-create-user-button">Create User</Button>
</AdminCreateUserDialog>
</div>
</PageHeader>
<PageBody>
<ServerDataLoader
table={'accounts'}
client={client}
page={page}
where={(queryBuilder) => {
const { account_type: type, query } = searchParams;
if (type && type !== 'all') {
queryBuilder.eq('is_personal_account', type === 'personal');
}
if (query) {
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%`);
}
return queryBuilder;
}}
>
{({ data, page, pageSize, pageCount }) => {
return (
<AdminAccountsTable
page={page}
pageSize={pageSize}
pageCount={pageCount}
data={data}
filters={{
type: searchParams.account_type ?? 'all',
query: searchParams.query ?? '',
}}
/>
);
}}
</ServerDataLoader>
</PageBody>
</>
);
}
export default AdminGuard(AccountsPage);

View File

@@ -0,0 +1,44 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/sidebar';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
export const metadata = {
title: `Super Admin`,
};
export const dynamic = 'force-dynamic';
export default function AdminLayout(props: React.PropsWithChildren) {
const state = use(getLayoutState());
return (
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<AdminSidebar />
</PageNavigation>
<PageMobileNavigation>
<AdminMobileNavigation />
</PageMobileNavigation>
{props.children}
</Page>
</SidebarProvider>
);
}
async function getLayoutState() {
const cookieStore = await cookies();
const sidebarOpenCookie = cookieStore.get('sidebar:state');
return {
open: sidebarOpenCookie?.value !== 'true',
};
}

View File

@@ -0,0 +1,17 @@
import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page';
function AdminPage() {
return (
<>
<PageHeader description={`Super Admin`} />
<PageBody>
<AdminDashboard />
</PageBody>
</>
);
}
export default AdminGuard(AdminPage);

View File

@@ -0,0 +1,74 @@
import Link from 'next/link';
import type { AuthError } from '@supabase/supabase-js';
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
interface AuthCallbackErrorPageProps {
searchParams: Promise<{
error: string;
callback?: string;
email?: string;
code?: AuthError['code'];
}>;
}
async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
const { error, callback, code } = await props.searchParams;
const signInPath = pathsConfig.auth.signIn;
const redirectPath = callback ?? pathsConfig.auth.callback;
return (
<div className={'flex flex-col space-y-4 py-4'}>
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'auth.authenticationErrorAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={error ?? 'auth.authenticationErrorAlertBody'} />
</AlertDescription>
</Alert>
<AuthCallbackForm
code={code}
signInPath={signInPath}
redirectPath={redirectPath}
/>
</div>
);
}
function AuthCallbackForm(props: {
signInPath: string;
redirectPath?: string;
code?: AuthError['code'];
}) {
switch (props.code) {
case 'otp_expired':
return <ResendAuthLinkForm redirectPath={props.redirectPath} />;
default:
return <SignInButton signInPath={props.signInPath} />;
}
}
function SignInButton(props: { signInPath: string }) {
return (
<Button
className={'w-full'}
render={
<Link href={props.signInPath}>
<Trans i18nKey={'auth.signIn'} />
</Link>
}
/>
);
}
export default AuthCallbackErrorPage;

View File

@@ -0,0 +1,18 @@
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());
const { nextPath } = await service.exchangeCodeForSession(request, {
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
});
return redirect(nextPath);
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());
const url = await service.verifyTokenHash(request, {
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
});
return NextResponse.redirect(url);
}

View File

@@ -0,0 +1,9 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
}
export default AuthLayout;

View File

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

View File

@@ -0,0 +1,56 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
export const generateMetadata = async () => {
const t = await getTranslations('auth');
return {
title: t('passwordResetLabel'),
};
};
const { callback, passwordUpdate, signIn } = pathsConfig.auth;
const redirectPath = `${callback}?next=${passwordUpdate}`;
function PasswordResetPage() {
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.passwordResetLabel'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.passwordResetSubheading'} />
</p>
</div>
<div className={'flex flex-col space-y-4'}>
<PasswordResetRequestContainer redirectPath={redirectPath} />
<div className={'flex justify-center text-xs'}>
<Button
nativeButton={false}
variant={'link'}
size={'sm'}
render={
<Link href={signIn}>
<Trans i18nKey={'auth.passwordRecoveredQuestion'} />
</Link>
}
/>
</div>
</div>
</>
);
}
export default PasswordResetPage;

View File

@@ -0,0 +1,71 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { getSafeRedirectPath } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
interface SignInPageProps {
searchParams: Promise<{
next?: string;
}>;
}
export const generateMetadata = async () => {
const t = await getTranslations('auth');
return {
title: t('signIn'),
};
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { next } = await searchParams;
const paths = {
callback: pathsConfig.auth.callback,
returnPath: getSafeRedirectPath(next, pathsConfig.app.home),
joinTeam: pathsConfig.app.joinTeam,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
paths={paths}
providers={authConfig.providers}
captchaSiteKey={authConfig.captchaTokenSiteKey}
/>
<div className={'flex justify-center'}>
<Button
nativeButton={false}
variant={'link'}
size={'sm'}
render={
<Link href={pathsConfig.auth.signUp} prefetch={true}>
<Trans i18nKey={'auth.doNotHaveAccountYet'} />
</Link>
}
/>
</div>
</>
);
}
export default SignInPage;

View File

@@ -0,0 +1,62 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
export const generateMetadata = async () => {
const t = await getTranslations('auth');
return {
title: t('signUp'),
};
};
const paths = {
callback: pathsConfig.auth.callback,
appHome: pathsConfig.app.home,
};
async function SignUpPage() {
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.signUpHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.signUpSubheading'} />
</p>
</div>
<SignUpMethodsContainer
providers={authConfig.providers}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
paths={paths}
captchaSiteKey={authConfig.captchaTokenSiteKey}
/>
<div className={'flex justify-center'}>
<Button
render={
<Link href={pathsConfig.auth.signIn} prefetch={true}>
<Trans i18nKey={'auth.alreadyHaveAnAccount'} />
</Link>
}
variant={'link'}
size={'sm'}
nativeButton={false}
/>
</div>
</>
);
}
export default SignUpPage;

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { getSafeRedirectPath } from '@kit/shared/utils';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
interface Props {
searchParams: Promise<{
next?: string;
}>;
}
export const generateMetadata = async () => {
const t = await getTranslations('auth');
return {
title: t('signIn'),
};
};
async function VerifyPage(props: Props) {
const client = getSupabaseServerClient();
const { data } = await client.auth.getClaims();
if (!data?.claims) {
redirect(pathsConfig.auth.signIn);
}
const needsMfa = await checkRequiresMultiFactorAuthentication(client);
if (!needsMfa) {
redirect(pathsConfig.auth.signIn);
}
const nextPath = (await props.searchParams).next;
const redirectPath = getSafeRedirectPath(nextPath, pathsConfig.app.home);
return (
<MultiFactorChallengeContainer
userId={data.claims.sub}
paths={{
redirectPath,
}}
/>
);
}
export default VerifyPage;

View File

@@ -0,0 +1,35 @@
'use client';
import { useCaptureException } from '@kit/monitoring/hooks';
import { useUser } from '@kit/supabase/hooks/use-user';
import { SiteHeader } from '~/(marketing)/_components/site-header';
import { ErrorPageContent } from '~/components/error-page-content';
const ErrorPage = ({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) => {
useCaptureException(error);
const user = useUser();
return (
<div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader user={user.data} />
<ErrorPageContent
statusCode={'common.errorPageHeading'}
heading={'common.genericError'}
subtitle={'common.genericErrorSubHeading'}
backLabel={'common.goBack'}
reset={reset}
/>
</div>
);
};
export default ErrorPage;

View File

@@ -0,0 +1,43 @@
'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import { SidebarContext } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const features = {
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
export function HomeAccountSelector(props: {
accounts: Array<{
label: string | null;
value: string | null;
image: string | null;
}>;
userId: string;
}) {
const router = useRouter();
const context = useContext(SidebarContext);
return (
<AccountSelector
collapsed={!context?.open}
accounts={props.accounts}
features={features}
userId={props.userId}
onAccountChange={(value) => {
if (value) {
const path = pathsConfig.app.accountHome.replace('[account]', value);
router.replace(path);
}
}}
/>
);
}

View File

@@ -0,0 +1,75 @@
import { use } from 'react';
import Link from 'next/link';
import {
CardButton,
CardButtonHeader,
CardButtonTitle,
} from '@kit/ui/card-button';
import {
EmptyState,
EmptyStateButton,
EmptyStateHeading,
EmptyStateText,
} from '@kit/ui/empty-state';
import { Trans } from '@kit/ui/trans';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { HomeAddAccountButton } from './home-add-account-button';
export function HomeAccountsList() {
const { accounts, canCreateTeamAccount } = use(loadUserWorkspace());
if (!accounts.length) {
return (
<HomeAccountsListEmptyState canCreateTeamAccount={canCreateTeamAccount} />
);
}
return (
<div className="flex flex-col">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{accounts.map((account) => (
<CardButton
key={account.value}
render={
<Link href={`/home/${account.value}`}>
<CardButtonHeader>
<CardButtonTitle>{account.label}</CardButtonTitle>
</CardButtonHeader>
</Link>
}
/>
))}
</div>
</div>
);
}
function HomeAccountsListEmptyState(props: {
canCreateTeamAccount: { allowed: boolean; reason?: string };
}) {
return (
<div className={'flex flex-1'}>
<EmptyState>
<EmptyStateButton
render={
<HomeAddAccountButton
className={'mt-4'}
canCreateTeamAccount={props.canCreateTeamAccount}
/>
}
/>
<EmptyStateHeading>
<Trans i18nKey={'account.noTeamsYet'} />
</EmptyStateHeading>
<EmptyStateText>
<Trans i18nKey={'account.createTeam'} />
</EmptyStateText>
</EmptyState>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useState } from 'react';
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Button } from '@kit/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
interface HomeAddAccountButtonProps {
className?: string;
canCreateTeamAccount?: {
allowed: boolean;
reason?: string;
};
}
export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
const [isAddingAccount, setIsAddingAccount] = useState(false);
const canCreate = props.canCreateTeamAccount?.allowed ?? true;
const reason = props.canCreateTeamAccount?.reason;
const button = (
<Button
className={props.className}
onClick={() => setIsAddingAccount(true)}
disabled={!canCreate}
>
<Trans i18nKey={'account.createTeamButtonLabel'} />
</Button>
);
return (
<>
{!canCreate && reason ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span className="cursor-not-allowed">{button}</span>}
/>
<TooltipContent>
<Trans i18nKey={reason} defaults={reason} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
button
)}
<CreateTeamAccountDialog
isOpen={isAddingAccount}
setIsOpen={setIsAddingAccount}
/>
</>
);
}

View File

@@ -0,0 +1,68 @@
import {
BorderedNavigationMenu,
BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu';
import { If } from '@kit/ui/if';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import { UserNotifications } from '../_components/user-notifications';
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
const { workspace, user, accounts } = props.workspace;
const routes = personalAccountNavigationConfig.routes.reduce<
Array<{
path: string;
label: string;
Icon?: React.ReactNode;
end?: boolean | ((path: string) => boolean);
}>
>((acc, item) => {
if ('children' in item) {
return [...acc, ...item.children];
}
if ('divider' in item) {
return acc;
}
return [...acc, item];
}, []);
return (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<BorderedNavigationMenu>
{routes.map((route) => (
<BorderedNavigationMenuItem {...route} key={route.path} />
))}
</BorderedNavigationMenu>
</div>
<div className={'flex justify-end space-x-2.5'}>
<UserNotifications userId={user.id} />
<If condition={featuresFlagConfig.enableTeamAccounts}>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</If>
<div>
<ProfileAccountDropdownContainer
user={user}
account={workspace}
showProfileName={false}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import Link from 'next/link';
import { LogOut, Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
const signOut = useSignOut();
const Links = personalAccountNavigationConfig.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 (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={featuresFlagConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common.yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<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>
}
key={props.path}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -0,0 +1,12 @@
import { PageHeader } from '@kit/ui/page';
export function HomeLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
}>,
) {
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
);
}

View File

@@ -0,0 +1,40 @@
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
interface HomeSidebarProps {
workspace: UserWorkspace;
}
export function HomeSidebar(props: HomeSidebarProps) {
const { workspace, user, accounts } = props.workspace;
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar variant="floating" collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-3'}>
<WorkspaceDropdown
user={user}
accounts={accounts}
workspace={workspace}
/>
<div className={'group-data-[collapsible=icon]:hidden'}>
<UserNotifications userId={user.id} />
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation config={personalAccountNavigationConfig} />
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,16 @@
import { NotificationsPopover } from '@kit/notifications/components';
import featuresFlagConfig from '~/config/feature-flags.config';
export function UserNotifications(props: { userId: string }) {
if (!featuresFlagConfig.enableNotifications) {
return null;
}
return (
<NotificationsPopover
accountIds={[props.userId]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
);
}

View File

@@ -0,0 +1,82 @@
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/policies';
import featureFlagsConfig from '~/config/feature-flags.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
export type UserWorkspace = Awaited<ReturnType<typeof loadUserWorkspace>>;
/**
* @name loadUserWorkspace
* @description
* Load the user workspace data. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/
export const loadUserWorkspace = cache(workspaceLoader);
async function workspaceLoader() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const accountsPromise = shouldLoadAccounts
? () => api.loadUserAccounts()
: () => Promise.resolve([]);
const workspacePromise = api.getAccountWorkspace();
const [accounts, workspace, user] = await Promise.all([
accountsPromise(),
workspacePromise,
requireUserInServerComponent(),
]);
// If the user is not found or the workspace is not found, redirect to the home page - this may happen if the JWT is invalid or expired (ex. user deleted?)
if (!workspace || !user) {
redirect('/');
}
// Check if user can create team accounts (policy check)
const canCreateTeamAccount = shouldLoadAccounts
? await checkCanCreateTeamAccount(user.id)
: { allowed: false, reason: undefined };
return {
accounts,
workspace,
user,
canCreateTeamAccount,
};
}
/**
* Check if the user can create a team account based on policies.
* Preliminary checks run without account name - name validation happens during submission.
*/
async function checkCanCreateTeamAccount(userId: string) {
const evaluator = createAccountCreationPolicyEvaluator();
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
if (!hasPolicies) {
return { allowed: true, reason: undefined };
}
const context = {
timestamp: new Date().toISOString(),
userId,
accountName: '',
};
const result = await evaluator.canCreateAccount(context, 'preliminary');
return {
allowed: result.allowed,
reason: result.reasons[0],
};
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { TriangleAlert } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { PlanPicker } from '@kit/billing-gateway/components';
import { useAppEvents } from '@kit/shared/events';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import { createPersonalAccountCheckoutSession } from '../_lib/server/server-actions';
const EmbeddedCheckout = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return {
default: EmbeddedCheckout,
};
},
{
ssr: false,
},
);
export function PersonalAccountCheckoutForm(props: {
customerId: string | null | undefined;
}) {
const [error, setError] = useState(false);
const appEvents = useAppEvents();
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
const { execute, isPending } = useAction(
createPersonalAccountCheckoutSession,
{
onSuccess: ({ data }) => {
if (data?.checkoutToken) {
setCheckoutToken(data.checkoutToken);
}
},
onError: () => {
setError(true);
},
},
);
// only allow trial if the user is not already a customer
const canStartTrial = !props.customerId;
// If the checkout token is set, render the embedded checkout component
if (checkoutToken) {
return (
<EmbeddedCheckout
checkoutToken={checkoutToken}
provider={billingConfig.provider}
onClose={() => setCheckoutToken(undefined)}
/>
);
}
// Otherwise, render the plan picker component
return (
<div>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'billing.planCardLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'billing.planCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-4'}>
<If condition={error}>
<ErrorAlert />
</If>
<PlanPicker
pending={isPending}
config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => {
appEvents.emit({
type: 'checkout.started',
payload: { planId },
});
execute({
planId,
productId,
});
}}
/>
</CardContent>
</Card>
</div>
);
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'common.planPickerAlertErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common.planPickerAlertErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { BillingPortalCard } from '@kit/billing-gateway/components';
import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions';
export function PersonalBillingPortalForm() {
const { execute } = useAction(createPersonalAccountBillingPortalSession);
return (
<form
onSubmit={(e) => {
e.preventDefault();
execute();
}}
>
<BillingPortalCard />
</form>
);
}

View File

@@ -0,0 +1,6 @@
import * as z from 'zod';
export const PersonalAccountCheckoutSchema = z.object({
planId: z.string().min(1),
productId: z.string().min(1),
});

View File

@@ -0,0 +1,27 @@
import 'server-only';
import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
/**
* Load the personal account billing page data for the given user.
* @param userId
* @returns The subscription data or the orders data and the billing customer ID.
* This function is cached per-request.
*/
export const loadPersonalAccountBillingPageData = cache(
personalAccountBillingPageDataLoader,
);
function personalAccountBillingPageDataLoader(userId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const subscription = api.getSubscription(userId);
const order = api.getOrder(userId);
const customerId = api.getCustomerId(userId);
return Promise.all([subscription, order, customerId]);
}

View File

@@ -0,0 +1,53 @@
'use server';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import featureFlagsConfig from '~/config/feature-flags.config';
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
import { createUserBillingService } from './user-billing.service';
/**
* @name enabled
* @description This feature flag is used to enable or disable personal account billing.
*/
const enabled = featureFlagsConfig.enablePersonalAccountBilling;
/**
* @name createPersonalAccountCheckoutSession
* @description Creates a checkout session for a personal account.
*/
export const createPersonalAccountCheckoutSession = authActionClient
.schema(PersonalAccountCheckoutSchema)
.action(async ({ parsedInput: data }) => {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createUserBillingService(client);
return await service.createCheckoutSession(data);
});
/**
* @name createPersonalAccountBillingPortalSession
* @description Creates a billing Portal session for a personal account
*/
export const createPersonalAccountBillingPortalSession =
authActionClient.action(async () => {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createUserBillingService(client);
// get url to billing portal
const url = await service.createBillingPortalSession();
redirect(url);
});

View File

@@ -0,0 +1,203 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getProductPlanPair } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { Database } from '~/lib/database.types';
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
export function createUserBillingService(client: SupabaseClient<Database>) {
return new UserBillingService(client);
}
/**
* @name UserBillingService
* @description Service for managing billing for personal accounts.
*/
class UserBillingService {
private readonly namespace = 'billing.personal-account';
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckoutSession
* @description Create a checkout session for the user
* @param planId
* @param productId
*/
async createCheckoutSession({
planId,
productId,
}: z.output<typeof PersonalAccountCheckoutSchema>) {
// get the authenticated user
const { data: user, error } = await requireUser(this.client);
if (error ?? !user) {
throw new Error('Authentication required');
}
const service = await getBillingGatewayProvider(this.client);
// in the case of personal accounts
// the account ID is the same as the user ID
const accountId = user.id;
// the return URL for the checkout session
const returnUrl = getCheckoutSessionReturnUrl();
// find the customer ID for the account if it exists
// (eg. if the account has been billed before)
const api = createAccountsApi(this.client);
const customerId = await api.getCustomerId(accountId);
const product = billingConfig.products.find(
(item) => item.id === productId,
);
if (!product) {
throw new Error('Product not found');
}
const { plan } = getProductPlanPair(billingConfig, planId);
const logger = await getLogger();
logger.info(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
},
`User requested a personal account checkout session. Contacting provider...`,
);
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
returnUrl,
accountId,
customerEmail: user.email,
customerId,
plan,
variantQuantities: [],
enableDiscountField: product.enableDiscountField,
});
logger.info(
{
userId: user.id,
},
`Checkout session created. Returning checkout token to client...`,
);
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
} catch (error) {
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
}
/**
* @name createBillingPortalSession
* @description Create a billing portal session for the user
* @returns The URL to redirect the user to the billing portal
*/
async createBillingPortalSession() {
const { data, error } = await requireUser(this.client);
if (error ?? !data) {
throw new Error('Authentication required');
}
const service = await getBillingGatewayProvider(this.client);
const logger = await getLogger();
const accountId = data.id;
const api = createAccountsApi(this.client);
const customerId = await api.getCustomerId(accountId);
const returnUrl = getBillingPortalReturnUrl();
if (!customerId) {
throw new Error('Customer not found');
}
const ctx = {
name: this.namespace,
customerId,
accountId,
};
logger.info(
ctx,
`User requested a Billing Portal session. Contacting provider...`,
);
let url: string;
try {
const session = await service.createBillingPortalSession({
customerId,
returnUrl,
});
url = session.url;
} catch (error) {
logger.error(
{
error,
...ctx,
},
`Failed to create a Billing Portal session`,
);
throw new Error(
`Encountered an error creating the Billing Portal session`,
{ cause: error },
);
}
logger.info(ctx, `Session successfully created.`);
// redirect user to billing portal
return url;
}
}
function getCheckoutSessionReturnUrl() {
return new URL(
pathsConfig.app.personalAccountBillingReturn,
appConfig.url,
).toString();
}
function getBillingPortalReturnUrl() {
return new URL(
pathsConfig.app.personalAccountBilling,
appConfig.url,
).toString();
}

View File

@@ -0,0 +1,7 @@
'use client';
// We reuse the page from the billing module
// as there is no need to create a new one.
import BillingErrorPage from '~/home/[account]/billing/error';
export default BillingErrorPage;

View 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;

View File

@@ -0,0 +1,105 @@
import { getTranslations } from 'next-intl/server';
import { resolveProductPlan } from '@kit/billing-gateway';
import {
CurrentLifetimeOrderCard,
CurrentSubscriptionCard,
} from '@kit/billing-gateway/components';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form';
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('billingTab');
return {
title,
};
};
async function PersonalAccountBillingPage() {
const user = await requireUserInServerComponent();
const [subscription, order, customerId] =
await loadPersonalAccountBillingPageData(user.id);
const subscriptionVariantId = subscription?.items[0]?.variant_id;
const orderVariantId = order?.items[0]?.variant_id;
const subscriptionProductPlan =
subscription && subscriptionVariantId
? await resolveProductPlan(
billingConfig,
subscriptionVariantId,
subscription.currency,
)
: undefined;
const orderProductPlan =
order && orderVariantId
? await resolveProductPlan(billingConfig, orderVariantId, order.currency)
: undefined;
const hasBillingData = subscription || order;
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common.routes.billing'} />}
description={<AppBreadcrumbs />}
/>
<div className={'flex max-w-2xl flex-col space-y-4'}>
<If
condition={hasBillingData}
fallback={
<>
<PersonalAccountCheckoutForm customerId={customerId} />
</>
}
>
<div className={'flex w-full flex-col space-y-6'}>
<If condition={subscription}>
{(subscription) => {
return (
<CurrentSubscriptionCard
subscription={subscription}
product={subscriptionProductPlan!.product}
plan={subscriptionProductPlan!.plan}
/>
);
}}
</If>
<If condition={order}>
{(order) => {
return (
<CurrentLifetimeOrderCard
order={order}
product={orderProductPlan!.product}
plan={orderProductPlan!.plan}
/>
);
}}
</If>
</div>
</If>
<If condition={customerId}>{() => <PersonalBillingPortalForm />}</If>
</div>
</PageBody>
);
}
export default PersonalAccountBillingPage;

View File

@@ -0,0 +1,5 @@
// We reuse the page from the billing module
// as there is no need to create a new one.
import ReturnCheckoutSessionPage from '~/home/[account]/billing/return/page';
export default ReturnCheckoutSessionPage;

View File

@@ -0,0 +1,140 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import * as z from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/sidebar';
import { AppLogo } from '~/components/app-logo';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
// home imports
import { HomeMenuNavigation } from './_components/home-menu-navigation';
import { HomeMobileNavigation } from './_components/home-mobile-navigation';
import { HomeSidebar } from './_components/home-sidebar';
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>;
}
export default UserHomeLayout;
async function SidebarLayout({ children }: React.PropsWithChildren) {
const [workspace, state] = await Promise.all([
loadUserWorkspace().catch(() => null),
getLayoutState(),
]);
if (!workspace) {
redirect('/');
}
redirectIfTeamsOnly(workspace);
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
redirectIfTeamsOnly(workspace);
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
{children}
</Page>
</UserWorkspaceContextProvider>
);
}
function MobileNavigation({
workspace,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
}) {
return (
<>
<AppLogo />
<HomeMobileNavigation workspace={workspace} />
</>
);
}
function redirectIfTeamsOnly(
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>,
) {
if (featuresFlagConfig.enableTeamsOnly) {
const firstTeam = workspace.accounts[0];
if (firstTeam?.value) {
redirect(
pathsConfig.app.accountHome.replace('[account]', firstTeam.value),
);
} else {
redirect(pathsConfig.app.createTeam);
}
}
}
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

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

View File

@@ -0,0 +1,29 @@
import { getTranslations } from 'next-intl/server';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { HomeLayoutPageHeader } from './_components/home-page-header';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('homePage');
return {
title,
};
};
function UserHomePage() {
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common.routes.home'} />}
description={<Trans i18nKey={'common.homeTabDescription'} />}
/>
</PageBody>
);
}
export default UserHomePage;

View File

@@ -0,0 +1,21 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account.routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{props.children}
</PageBody>
);
}
export default UserSettingsLayout;

View File

@@ -0,0 +1,58 @@
import { use } from 'react';
import { getTranslations } from 'next-intl/server';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import authConfig from '~/config/auth.config';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
// Show email option if password, magic link, or OTP is enabled
const showEmailOption =
authConfig.providers.password ||
authConfig.providers.magicLink ||
authConfig.providers.otp;
const features = {
showLinkEmailOption: showEmailOption,
enablePasswordUpdate: authConfig.providers.password,
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enableAccountLinking: authConfig.enableIdentityLinking,
};
const providers = authConfig.providers.oAuth;
const callbackPath = pathsConfig.auth.callback;
const accountSettingsPath = pathsConfig.app.accountSettings;
const paths = {
callback: callbackPath + `?next=${accountSettingsPath}`,
};
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('settingsTab');
return {
title,
};
};
function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent());
return (
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
providers={providers}
/>
</div>
);
}
export default PersonalAccountSettingsPage;

View File

@@ -0,0 +1,921 @@
'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 { useIsMobile } from '@kit/ui/hooks/use-mobile';
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={
'animate-in fade-in flex flex-col space-y-4 pb-36 duration-500'
}
>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-2 xl: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: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: '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={(props) => <ChartTooltipContent hideLabel {...props} />}
/>
<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-muted-foreground text-sm'}>
{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={'text-destructive h-3 w-3'} />;
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 isMobile = useIsMobile();
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: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: 'var(--chart-2)',
},
} satisfies ChartConfig;
return (
<Card>
<CardHeader>
<CardTitle>Visitors</CardTitle>
<CardDescription>
Showing total visitors for the last 6 months
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={chartData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<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="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
cursor={false}
defaultIndex={isMobile ? -1 : 10}
content={(props) => (
<ChartTooltipContent
{...props}
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
indicator="dot"
/>
)}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
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 leading-none font-medium">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground flex items-center gap-2 leading-none">
January - June 2024
</div>
</div>
</div>
</CardFooter>
</Card>
);
}
export function PageViewsChart() {
const [activeChart, setActiveChart] =
useState<keyof typeof chartConfig>('desktop');
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 = {
views: {
label: 'Page Views',
},
desktop: {
label: 'Desktop',
color: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: '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),
}),
[chartData],
);
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="data-[active=true]:bg-muted/50 relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-muted-foreground text-xs">
{chartConfig[chart].label}
</span>
<span className="text-lg leading-none font-bold 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={(props) => (
<ChartTooltipContent
{...props}
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

@@ -0,0 +1,15 @@
'use client';
import dynamic from 'next/dynamic';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
export const DashboardDemo = dynamic(() => import('./dashboard-demo-charts'), {
ssr: false,
loading: () => (
<LoadingOverlay
fullPage={false}
className={'flex flex-1 flex-col items-center justify-center'}
/>
),
});

View File

@@ -0,0 +1,49 @@
'use client';
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSidebar } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const features = {
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
export function TeamAccountAccountsSelector(params: {
selectedAccount: string;
userId: string;
accounts: Array<{
label: string | null;
value: string | null;
image: string | null;
}>;
}) {
const router = useRouter();
const ctx = useSidebar();
return (
<AccountSelector
selectedAccount={params.selectedAccount}
accounts={params.accounts}
userId={params.userId}
collapsed={!ctx?.open}
features={features}
showPersonalAccount={!featureFlagsConfig.enableTeamsOnly}
onAccountChange={(value) => {
if (!value && featureFlagsConfig.enableTeamsOnly) {
return;
}
const path = value
? pathsConfig.app.accountHome.replace('[account]', value)
: pathsConfig.app.home;
router.replace(path);
}}
/>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react';
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';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{
label: string | null;
value: string | null;
image: string | null;
}>;
const features = {
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
export const TeamAccountLayoutMobileNavigation = (
props: React.PropsWithChildren<{
account: string;
userId: string;
accounts: Accounts;
}>,
) => {
const signOut = useSignOut();
const Links = getTeamAccountSidebarConfig(props.account).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 (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<TeamAccountsModal
userId={props.userId}
accounts={props.accounts}
account={props.account}
/>
{Links}
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common.signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;
userId: string;
account: string;
}) {
const router = useRouter();
return (
<Dialog>
<DialogTrigger
render={
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onSelect={(e) => e.preventDefault()}
>
<Home className={'h-4'} />
<span>
<Trans i18nKey={'common.yourAccounts'} />
</span>
</DropdownMenuItem>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'common.yourAccounts'} />
</DialogTitle>
</DialogHeader>
<div className={'py-6'}>
<AccountSelector
className={'w-full max-w-full'}
userId={props.userId}
accounts={props.accounts}
features={features}
selectedAccount={props.account}
onAccountChange={(value) => {
const path = value
? pathsConfig.app.accountHome.replace('[account]', value)
: pathsConfig.app.home;
router.replace(path);
}}
/>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import { PageHeader } from '@kit/ui/page';
export function TeamAccountLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
account: string;
}>,
) {
return (
<PageHeader description={props.description}>{props.children}</PageHeader>
);
}

View File

@@ -0,0 +1,12 @@
import * as z from 'zod';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
export function TeamAccountLayoutSidebarNavigation({
config,
}: React.PropsWithChildren<{
config: z.output<typeof NavigationConfigSchema>;
}>) {
return <SidebarNavigation config={config} />;
}

View File

@@ -0,0 +1,46 @@
import { JWTUserData } from '@kit/supabase/types';
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import type { AccountModel } from '~/components/workspace-dropdown';
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
export function TeamAccountLayoutSidebar(props: {
account: string;
accountId: string;
accounts: AccountModel[];
user: JWTUserData;
}) {
const { account, accounts, user } = props;
const config = getTeamAccountSidebarConfig(account);
const collapsible = config.sidebarCollapsedStyle;
return (
<Sidebar variant="floating" collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-1'}>
<WorkspaceDropdown
user={user}
accounts={accounts}
selectedAccount={account}
/>
<div className={'group-data-[collapsible=icon]:hidden'}>
<TeamAccountNotifications
userId={user.id}
accountId={props.accountId}
/>
</div>
</div>
</SidebarHeader>
<SidebarContent className="h-[calc(100%-160px)] overflow-y-auto">
<TeamAccountLayoutSidebarNavigation config={config} />
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,74 @@
import {
BorderedNavigationMenu,
BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
// local imports
import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader';
import { TeamAccountNotifications } from './team-account-notifications';
export function TeamAccountNavigationMenu(props: {
workspace: TeamAccountWorkspace;
}) {
const { account, user, accounts } = props.workspace;
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
Array<{
path: string;
label: string;
Icon?: React.ReactNode;
end?: boolean | ((path: string) => boolean);
}>
>((acc, item) => {
if ('children' in item) {
return [...acc, ...item.children];
}
if ('divider' in item) {
return acc;
}
return [...acc, item];
}, []);
return (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<BorderedNavigationMenu>
{routes.map((route) => (
<BorderedNavigationMenuItem {...route} key={route.path} />
))}
</BorderedNavigationMenu>
</div>
<div className={'flex items-center justify-end space-x-2.5'}>
<TeamAccountNotifications accountId={account.id} userId={user.id} />
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
<div>
<ProfileAccountDropdownContainer
user={user}
showProfileName={false}
accountSlug={account.slug}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { NotificationsPopover } from '@kit/notifications/components';
import featuresFlagConfig from '~/config/feature-flags.config';
export function TeamAccountNotifications(params: {
userId: string;
accountId: string;
}) {
if (!featuresFlagConfig.enableNotifications) {
return null;
}
return (
<NotificationsPopover
accountIds={[params.userId, params.accountId]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
);
}

View File

@@ -0,0 +1,23 @@
import 'server-only';
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
/**
* @name loadTeamAccountBillingPage
* @description Load the team account billing page data for the given account.
*/
export const loadTeamAccountBillingPage = cache(teamAccountBillingPageLoader);
function teamAccountBillingPageLoader(accountId: string) {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const subscription = api.getSubscription(accountId);
const order = api.getOrder(accountId);
const customerId = api.getCustomerId(accountId);
return Promise.all([subscription, order, customerId]);
}

View File

@@ -0,0 +1,47 @@
import 'server-only';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import pathsConfig from '~/config/paths.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
export type TeamAccountWorkspace = Awaited<
ReturnType<typeof loadTeamWorkspace>
>;
/**
* Load the account 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 account workspace.
* It is cached so that the data is only fetched once per request.
*
* @param accountSlug
*/
export const loadTeamWorkspace = cache(workspaceLoader);
async function workspaceLoader(accountSlug: string) {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const [workspace, user] = await Promise.all([
api.getAccountWorkspace(accountSlug),
requireUserInServerComponent(),
]);
// we cannot find any record for the selected account
// so we redirect the user to the home page
if (!workspace.data?.account) {
return redirect(pathsConfig.app.home);
}
return {
...workspace.data,
user,
};
}

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,
},
);

Some files were not shown because too many files have changed in this diff Show More