Cleanup
This commit is contained in:
93
apps/web/app/(marketing)/about/page.tsx
Normal file
93
apps/web/app/(marketing)/about/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
export const metadata = {
|
||||
title: 'About',
|
||||
};
|
||||
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-14'}>
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<Heading level={1}>About us</Heading>
|
||||
|
||||
<Heading level={2}>
|
||||
We are a team of passionate developers and designers who love to
|
||||
build great products.
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'm-auto flex w-full max-w-xl flex-col items-center space-y-8' +
|
||||
' justify-center text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
We are a team of visionaries, dreamers, and doers who are on a
|
||||
mission to change the world for the better
|
||||
</div>
|
||||
|
||||
<div>
|
||||
With a passion for innovation and a commitment to excellence, we
|
||||
are dedicated to creating products and services that will improve
|
||||
people's lives and make a positive impact on society.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
It all started with a simple idea: to use technology to solve some
|
||||
of the biggest challenges facing humanity. We realized that with
|
||||
the right team and the right approach, we could make a difference
|
||||
and leave a lasting legacy. And so, with a lot of hard work and
|
||||
determination, we set out on a journey to turn our vision into
|
||||
reality.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Today, we are proud to be a leader in our field, and our products
|
||||
and services are used by millions of people all over the world.
|
||||
But we're not done yet. We still have big dreams and even
|
||||
bigger plans, and we're always looking for ways to push the
|
||||
boundaries of what's possible.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Our Values: At the heart of everything we do is a set of core
|
||||
values that guide us in all that we do. These values are what make
|
||||
us who we are, and they are what set us apart from the rest.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul className={'flex list-decimal flex-col space-y-1 pl-4'}>
|
||||
<li>
|
||||
Innovation: We are always looking for new and better ways to
|
||||
do things.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Excellence: We strive for excellence in all that we do, and we
|
||||
never settle for less.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Responsibility: We take our responsibilities seriously, and we
|
||||
always act with integrity.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Collaboration: We believe that by working together, we can
|
||||
achieve more than we can on our own.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>Yes, this was generated with ChatGPT</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
70
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import Post from '../components/post';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata | undefined> {
|
||||
const post = allPosts.find((post) => post.slug === params.slug);
|
||||
|
||||
if (!post) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, date, description, image, slug } = post;
|
||||
const url = [appConfig.url, 'blog', slug].join('/');
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: date,
|
||||
url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function BlogPost({ params }: { params: { slug: string } }) {
|
||||
const post = allPosts.find((post) => post.slug === params.slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<Script id={'ld-json'} type="application/ld+json">
|
||||
{JSON.stringify(post.structuredData)}
|
||||
</Script>
|
||||
|
||||
<Post post={post} content={post.body.code} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPost);
|
||||
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal file
33
apps/web/app/(marketing)/blog/components/cover-image.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
src: string;
|
||||
preloadImage?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CoverImage: React.FC<Props> = ({
|
||||
title,
|
||||
src,
|
||||
preloadImage,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Image
|
||||
className={cn(
|
||||
'duration-250 block rounded-xl object-cover' +
|
||||
' transition-all hover:opacity-90',
|
||||
{
|
||||
className,
|
||||
},
|
||||
)}
|
||||
src={src}
|
||||
priority={preloadImage}
|
||||
alt={`Cover Image for ${title}`}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
};
|
||||
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal file
11
apps/web/app/(marketing)/blog/components/date-formatter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
export const DateFormatter = ({ dateString }: Props) => {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DraftPostBadge({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal file
56
apps/web/app/(marketing)/blog/components/post-header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
const PostHeader: React.FC<{
|
||||
post: Post;
|
||||
}> = ({ post }) => {
|
||||
const { title, date, readingTime, description, image } = post;
|
||||
|
||||
// NB: change this to display the post's image
|
||||
const displayImage = true;
|
||||
const preloadImage = true;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'font-normal text-muted-foreground'}>
|
||||
{description}
|
||||
</span>
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="flex flex-row items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<DateFormatter dateString={date} />
|
||||
</div>
|
||||
|
||||
<span>·</span>
|
||||
<span>{readingTime} minutes reading</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={displayImage && image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mx-auto h-[378px] w-full justify-center">
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
className="rounded-md"
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostHeader;
|
||||
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal file
70
apps/web/app/(marketing)/blog/components/post-preview.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
preloadImage?: boolean;
|
||||
imageHeight?: string | number;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_HEIGHT = 250;
|
||||
|
||||
function PostPreview({
|
||||
post,
|
||||
preloadImage,
|
||||
imageHeight,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const { title, image, date, readingTime, description } = post;
|
||||
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl transition-shadow duration-500 dark:text-gray-800">
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mb-2 w-full" style={{ height }}>
|
||||
<Link href={post.url}>
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<div className={'px-1'}>
|
||||
<div className="flex flex-col space-y-1 px-1 py-2">
|
||||
<h3 className="px-1 text-2xl font-bold leading-snug dark:text-white">
|
||||
<Link href={post.url} className="hover:underline">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-row items-center space-x-2 px-1 text-sm">
|
||||
<div className="text-gray-600 dark:text-gray-300">
|
||||
<DateFormatter dateString={date} />
|
||||
</div>
|
||||
|
||||
<span className="text-gray-600 dark:text-gray-300">·</span>
|
||||
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{readingTime} mins reading
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 px-1 text-sm leading-relaxed dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostPreview;
|
||||
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal file
24
apps/web/app/(marketing)/blog/components/post.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Post as PostType } from 'contentlayer/generated';
|
||||
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
import PostHeader from './post-header';
|
||||
|
||||
export const Post: React.FC<{
|
||||
post: PostType;
|
||||
content: string;
|
||||
}> = ({ post, content }) => {
|
||||
return (
|
||||
<div className={'mx-auto my-8 max-w-2xl'}>
|
||||
<PostHeader post={post} />
|
||||
|
||||
<article className={'mx-auto flex justify-center'}>
|
||||
<Mdx code={content} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Post;
|
||||
41
apps/web/app/(marketing)/blog/page.tsx
Normal file
41
apps/web/app/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
import PostPreview from '~/(marketing)/blog/components/post-preview';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { GridList } from '../components/grid-list';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Blog - ${appConfig.name}`,
|
||||
description: `Tutorials, Guides and Updates from our team`,
|
||||
};
|
||||
|
||||
async function BlogPage() {
|
||||
const livePosts = allPosts.filter((post) => {
|
||||
const isProduction = appConfig.production;
|
||||
|
||||
return isProduction ? post.live : true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Blog'}
|
||||
subtitle={'Tutorials, Guides and Updates from our team'}
|
||||
/>
|
||||
|
||||
<GridList>
|
||||
{livePosts.map((post, idx) => {
|
||||
return <PostPreview key={idx} post={post} />;
|
||||
})}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPage);
|
||||
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal file
7
apps/web/app/(marketing)/components/grid-list.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function GridList({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="mb-16 grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-8 md:gap-y-12 lg:grid-cols-3 lg:gap-x-12">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal file
132
apps/web/app/(marketing)/components/site-footer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className={'py-8 lg:py-24'}>
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex flex-col space-y-8 lg:flex-row lg:space-y-0'}>
|
||||
<div
|
||||
className={
|
||||
'flex w-full space-x-2 lg:w-4/12 xl:w-3/12' +
|
||||
' xl:space-x-6 2xl:space-x-8'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<AppLogo className={'w-[85px] md:w-[115px]'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
Add a short tagline about your product
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex text-xs text-gray-500 dark:text-gray-400'}>
|
||||
<p>
|
||||
© Copyright {YEAR} {appConfig.name}. All Rights Reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col space-y-8 lg:space-x-6 lg:space-y-0' +
|
||||
' xl:space-x-16 2xl:space-x-20' +
|
||||
' w-full lg:flex-row lg:justify-end'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>About</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Who we are</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'/blog'}>Blog</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'/contact'}>Contact</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>Product</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'/docs'}>Documentation</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Help Center</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Changelog</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FooterSectionHeading>Legal</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Terms of Service</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Privacy Policy</Link>
|
||||
</FooterLink>
|
||||
<FooterLink>
|
||||
<Link href={'#'}>Cookie Policy</Link>
|
||||
</FooterLink>
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSectionHeading(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<p>
|
||||
<span className={'font-semibold'}>{props.children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSectionList(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
'text-sm [&>a]:transition-colors [&>a]:hover:text-gray-800' +
|
||||
' dark:[&>a]:hover:text-white'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function SiteHeaderAccountSection() {
|
||||
const signOut = useSignOut();
|
||||
const userSession = useUserSession();
|
||||
|
||||
if (userSession.data) {
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
session={userSession.data}
|
||||
paths={{
|
||||
home: pathsConfig.app.home,
|
||||
}}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthButtons />;
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
return (
|
||||
<div className={'hidden space-x-2 lg:flex'}>
|
||||
<Button variant={'link'}>
|
||||
<Link href={pathsConfig.auth.signIn}>Sign In</Link>
|
||||
</Button>
|
||||
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Button className={'rounded-full'}>
|
||||
Sign Up
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/app/(marketing)/components/site-header.tsx
Normal file
29
apps/web/app/(marketing)/components/site-header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { SiteHeaderAccountSection } from '~/(marketing)/components/site-header-account-section';
|
||||
import { SiteNavigation } from '~/(marketing)/components/site-navigation';
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className={'w-4/12'}>
|
||||
<AppLogo />
|
||||
</div>
|
||||
|
||||
<div className={'hidden w-4/12 justify-center lg:flex'}>
|
||||
<SiteNavigation />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-1 items-center justify-end space-x-4'}>
|
||||
<div className={'flex items-center'}></div>
|
||||
|
||||
<SiteHeaderAccountSection />
|
||||
|
||||
<div className={'flex lg:hidden'}>
|
||||
<SiteNavigation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal file
102
apps/web/app/(marketing)/components/site-navigation.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
} from '@kit/ui/navigation-menu';
|
||||
|
||||
const links = {
|
||||
SignIn: {
|
||||
label: 'Sign In',
|
||||
path: '/auth/sign-in',
|
||||
},
|
||||
Blog: {
|
||||
label: 'Blog',
|
||||
path: '/blog',
|
||||
},
|
||||
Docs: {
|
||||
label: 'Documentation',
|
||||
path: '/docs',
|
||||
},
|
||||
Pricing: {
|
||||
label: 'Pricing',
|
||||
path: '/pricing',
|
||||
},
|
||||
FAQ: {
|
||||
label: 'FAQ',
|
||||
path: '/faq',
|
||||
},
|
||||
};
|
||||
|
||||
export function SiteNavigation() {
|
||||
const className = `hover:underline text-sm`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'hidden items-center lg:flex'}>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className={'space-x-2.5'}>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Blog.path}>
|
||||
{links.Blog.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Docs.path}>
|
||||
{links.Docs.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.Pricing.path}>
|
||||
{links.Pricing.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link className={className} href={links.FAQ.path}>
|
||||
{links.FAQ.label}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center lg:hidden'}>
|
||||
<MobileDropdown />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={'Open Menu'}>
|
||||
<MenuIcon className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{Object.values(links).map((item) => {
|
||||
const className = 'flex w-full h-full items-center';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={item.path}>
|
||||
<Link className={className} href={item.path}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal file
20
apps/web/app/(marketing)/components/site-page-header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function SitePageHeader(props: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center space-y-2.5', props.className)}
|
||||
>
|
||||
<Heading level={1}>{props.title}</Heading>
|
||||
|
||||
<Heading level={2} className={'text-muted-foreground'}>
|
||||
<span className={'font-normal'}>{props.subtitle}</span>
|
||||
</Heading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
101
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
|
||||
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
|
||||
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
const getPageBySlug = cache((slug: string) => {
|
||||
return allDocumentationPages.find((post) => post.resolvedPath === slug);
|
||||
});
|
||||
|
||||
interface PageParams {
|
||||
params: {
|
||||
slug: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params }: PageParams) => {
|
||||
const page = getPageBySlug(params.slug.join('/'));
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, description } = page;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
};
|
||||
};
|
||||
|
||||
function DocumentationPage({ params }: PageParams) {
|
||||
const page = getPageBySlug(params.slug.join('/'));
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { nextPage, previousPage, children } =
|
||||
getDocumentationPageTree(page.resolvedPath) ?? {};
|
||||
|
||||
const description = page?.description ?? '';
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'relative flex grow flex-col space-y-4 px-8 py-8'}>
|
||||
<SitePageHeader
|
||||
title={page.title}
|
||||
subtitle={description}
|
||||
className={'items-start'}
|
||||
/>
|
||||
|
||||
<Mdx code={page.body.code} />
|
||||
|
||||
<If condition={children}>
|
||||
<DocsCards pages={children ?? []} />
|
||||
</If>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col justify-between space-y-4 md:flex-row md:space-x-8' +
|
||||
' md:space-y-0'
|
||||
}
|
||||
>
|
||||
<div className={'w-full'}>
|
||||
<If condition={previousPage}>
|
||||
{(page) => (
|
||||
<DocumentationPageLink
|
||||
page={page}
|
||||
before={<ChevronLeftIcon className={'w-4'} />}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'w-full'}>
|
||||
<If condition={nextPage}>
|
||||
{(page) => (
|
||||
<DocumentationPageLink
|
||||
page={page}
|
||||
after={<ChevronRightIcon className={'w-4'} />}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(DocumentationPage);
|
||||
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal file
45
apps/web/app/(marketing)/docs/components/docs-card.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
export const DocsCard: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
label: string;
|
||||
subtitle?: string | null;
|
||||
link?: { url: string; label: string };
|
||||
}>
|
||||
> = ({ label, subtitle, children, link }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={`flex grow flex-col space-y-2.5 border bg-background p-6
|
||||
${link ? 'rounded-t-2xl border-b-0' : 'rounded-2xl'}`}
|
||||
>
|
||||
<h3 className="mt-0 text-lg font-semibold dark:text-white">{label}</h3>
|
||||
|
||||
{subtitle && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className="text-sm">{children}</div>}
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="rounded-b-2xl border bg-muted p-6 py-4 dark:bg-background">
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<Link
|
||||
className={'text-sm font-medium hover:underline'}
|
||||
href={`/docs/${link.url}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal file
23
apps/web/app/(marketing)/docs/components/docs-cards.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
import { DocsCard } from './docs-card';
|
||||
|
||||
export function DocsCards({ pages }: { pages: DocumentationPage[] }) {
|
||||
return (
|
||||
<div className={'grid grid-cols-1 gap-8 lg:grid-cols-2'}>
|
||||
{pages.map((item) => {
|
||||
return (
|
||||
<DocsCard
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
subtitle={item.description}
|
||||
link={{
|
||||
url: item.resolvedPath,
|
||||
label: item.cardCTA ?? 'Read more',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal file
225
apps/web/app/(marketing)/docs/components/docs-navigation.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ChevronDownIcon, MenuIcon } from 'lucide-react';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree';
|
||||
|
||||
const DocsNavLink: React.FC<{
|
||||
label: string;
|
||||
url: string;
|
||||
level: number;
|
||||
activePath: string;
|
||||
collapsible: boolean;
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
}> = ({
|
||||
label,
|
||||
url,
|
||||
level,
|
||||
activePath,
|
||||
collapsible,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
}) => {
|
||||
const isCurrent = url == activePath;
|
||||
const isFirstLevel = level === 0;
|
||||
|
||||
return (
|
||||
<div className={getNavLinkClassName(isCurrent, isFirstLevel)}>
|
||||
<Link
|
||||
className="flex h-full max-w-full grow items-center space-x-2"
|
||||
href={`/docs/${url}`}
|
||||
>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
</Link>
|
||||
|
||||
{collapsible && (
|
||||
<button
|
||||
aria-label="Toggle children"
|
||||
onClick={toggleCollapsed}
|
||||
className="mr-2 shrink-0 px-2 py-1"
|
||||
>
|
||||
<span
|
||||
className={`block w-2.5 ${collapsed ? '-rotate-90 transform' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Node: React.FC<{
|
||||
node: ProcessedDocumentationPage;
|
||||
level: number;
|
||||
activePath: string;
|
||||
}> = ({ node, level, activePath }) => {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(node.collapsed ?? false);
|
||||
const toggleCollapsed = () => setCollapsed(!collapsed);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activePath == node.resolvedPath ||
|
||||
node.children.map((_) => _.resolvedPath).includes(activePath)
|
||||
) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
}, [activePath, node.children, node.resolvedPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocsNavLink
|
||||
label={node.label}
|
||||
url={node.resolvedPath}
|
||||
level={level}
|
||||
activePath={activePath}
|
||||
collapsible={node.collapsible}
|
||||
collapsed={collapsed}
|
||||
toggleCollapsed={toggleCollapsed}
|
||||
/>
|
||||
|
||||
{node.children.length > 0 && !collapsed && (
|
||||
<Tree tree={node.children} level={level + 1} activePath={activePath} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function Tree({
|
||||
tree,
|
||||
level,
|
||||
activePath,
|
||||
}: {
|
||||
tree: ProcessedDocumentationPage[];
|
||||
level: number;
|
||||
activePath: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('w-full space-y-2.5 pl-3', level > 0 ? 'border-l' : '')}>
|
||||
{tree.map((treeNode, index) => (
|
||||
<Node
|
||||
key={index}
|
||||
node={treeNode}
|
||||
level={level}
|
||||
activePath={activePath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsNavigation({
|
||||
tree,
|
||||
}: {
|
||||
tree: ProcessedDocumentationPage[];
|
||||
}) {
|
||||
const activePath = usePathname().replace('/docs/', '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
style={{
|
||||
height: `calc(100vh - 64px)`,
|
||||
}}
|
||||
className="sticky top-2 hidden w-80 shrink-0 border-r p-4 lg:flex"
|
||||
>
|
||||
<Tree tree={tree} level={0} activePath={activePath} />
|
||||
</aside>
|
||||
|
||||
<div className={'lg:hidden'}>
|
||||
<FloatingDocumentationNavigation tree={tree} activePath={activePath} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) {
|
||||
return cn(
|
||||
'group flex h-8 items-center justify-between space-x-2 whitespace-nowrap rounded-md px-3 text-sm leading-none transition-colors',
|
||||
{
|
||||
[`bg-muted`]: isCurrent,
|
||||
[`hover:bg-muted`]: !isCurrent,
|
||||
[`font-semibold`]: isFirstLevel,
|
||||
[`font-normal`]: !isFirstLevel && isCurrent,
|
||||
[`hover:text-foreground-muted`]: !isFirstLevel && !isCurrent,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingDocumentationNavigation({
|
||||
tree,
|
||||
activePath,
|
||||
}: React.PropsWithChildren<{
|
||||
tree: ProcessedDocumentationPage[];
|
||||
activePath: string;
|
||||
}>) {
|
||||
const body = useMemo(() => {
|
||||
return isBrowser() ? document.body : null;
|
||||
}, []);
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const enableScrolling = (element: HTMLElement) =>
|
||||
(element.style.overflowY = '');
|
||||
|
||||
const disableScrolling = (element: HTMLElement) =>
|
||||
(element.style.overflowY = 'hidden');
|
||||
|
||||
// enable/disable body scrolling when the docs are toggled
|
||||
useEffect(() => {
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
disableScrolling(body);
|
||||
} else {
|
||||
enableScrolling(body);
|
||||
}
|
||||
}, [isVisible, body]);
|
||||
|
||||
// hide docs when navigating to another page
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
}, [activePath]);
|
||||
|
||||
const onClick = () => {
|
||||
setIsVisible(!isVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={isVisible}>
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 z-10 h-screen w-full p-4' +
|
||||
' flex flex-col space-y-4 overflow-auto bg-white dark:bg-background'
|
||||
}
|
||||
>
|
||||
<Heading level={1}>Table of Contents</Heading>
|
||||
|
||||
<Tree tree={tree} level={0} activePath={activePath} />
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<Button
|
||||
className={'fixed bottom-5 right-5 z-10 h-16 w-16 rounded-full'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MenuIcon className={'h-8'} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function DocumentationPageLink({
|
||||
page,
|
||||
before,
|
||||
after,
|
||||
}: React.PropsWithChildren<{
|
||||
page: DocumentationPage;
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
`flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 ring-muted transition-all hover:ring-primary`,
|
||||
{
|
||||
'justify-start': before,
|
||||
'justify-end self-end': after,
|
||||
},
|
||||
)}
|
||||
href={`/docs/${page.resolvedPath}`}
|
||||
>
|
||||
<If condition={before}>{(node) => <>{node}</>}</If>
|
||||
|
||||
<span className={'flex flex-col space-y-1.5'}>
|
||||
<span
|
||||
className={
|
||||
'text-xs font-semibold uppercase dark:text-gray-400' +
|
||||
' text-gray-500'
|
||||
}
|
||||
>
|
||||
{before ? `Previous` : ``}
|
||||
{after ? `Next` : ``}
|
||||
</span>
|
||||
|
||||
<span>{page.title}</span>
|
||||
</span>
|
||||
|
||||
<If condition={after}>{(node) => <>{node}</>}</If>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/(marketing)/docs/layout.tsx
Normal file
21
apps/web/app/(marketing)/docs/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import DocsNavigation from './components/docs-navigation';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
|
||||
function DocsLayout({ children }: React.PropsWithChildren) {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex'}>
|
||||
<DocsNavigation tree={tree} />
|
||||
|
||||
<div className={'flex w-full flex-col items-center'}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocsLayout;
|
||||
30
apps/web/app/(marketing)/docs/page.tsx
Normal file
30
apps/web/app/(marketing)/docs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
import { DocsCards } from './components/docs-cards';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
|
||||
export const metadata = {
|
||||
title: `Documentation - ${appConfig.name}`,
|
||||
};
|
||||
|
||||
function DocsPage() {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
return (
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Documentation'}
|
||||
subtitle={'Get started with our guides and tutorials'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<DocsCards pages={tree ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(DocsPage);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
|
||||
export interface ProcessedDocumentationPage extends DocumentationPage {
|
||||
collapsible: boolean;
|
||||
pathSegments: string[];
|
||||
nextPage: ProcessedDocumentationPage | DocumentationPage | null;
|
||||
previousPage: ProcessedDocumentationPage | DocumentationPage | null;
|
||||
children: DocsTree;
|
||||
}
|
||||
|
||||
export type DocsTree = ProcessedDocumentationPage[];
|
||||
|
||||
/**
|
||||
* Build a tree of documentation pages from a flat list of pages with path segments
|
||||
* @param docs
|
||||
* @param parentPathNames
|
||||
*/
|
||||
export const buildDocumentationTree = cache(
|
||||
(docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => {
|
||||
const level = parentPathNames.length;
|
||||
|
||||
const pages = docs
|
||||
.filter(
|
||||
(_) =>
|
||||
_.pathSegments.length === level + 1 &&
|
||||
_.pathSegments
|
||||
.map(({ pathName }: { pathName: string }) => pathName)
|
||||
.join('/')
|
||||
.startsWith(parentPathNames.join('/')),
|
||||
)
|
||||
.sort(
|
||||
(a, b) => a.pathSegments[level].order - b.pathSegments[level].order,
|
||||
);
|
||||
|
||||
return pages.map((doc, index) => {
|
||||
const children = buildDocumentationTree(
|
||||
docs,
|
||||
doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName),
|
||||
);
|
||||
|
||||
return {
|
||||
...doc,
|
||||
pathSegments: doc.pathSegments || ([] as string[]),
|
||||
collapsible: children.length > 0,
|
||||
nextPage: children[0] || pages[index + 1],
|
||||
previousPage: pages[index - 1],
|
||||
children,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import { buildDocumentationTree } from './build-documentation-tree';
|
||||
|
||||
/**
|
||||
* Retrieves a specific documentation page from the page tree by its path.
|
||||
*
|
||||
* @param {string} pagePath - The path of the documentation page to retrieve.
|
||||
* @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any.
|
||||
*/
|
||||
export const getDocumentationPageTree = cache((pagePath: string) => {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
type DocumentationPageWithChildren = DocumentationPage & {
|
||||
previousPage?: DocumentationPage | null;
|
||||
nextPage?: DocumentationPage | null;
|
||||
children?: DocumentationPage[];
|
||||
};
|
||||
|
||||
const findPageInTree = (
|
||||
pages: DocumentationPageWithChildren[],
|
||||
path: string,
|
||||
): DocumentationPageWithChildren | undefined => {
|
||||
for (const page of pages) {
|
||||
if (page.resolvedPath === path) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const hasChildren = page.children && page.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
const foundPage = findPageInTree(page.children ?? [], path);
|
||||
|
||||
if (foundPage) {
|
||||
return foundPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return findPageInTree(tree, pagePath);
|
||||
});
|
||||
123
apps/web/app/(marketing)/faq/page.tsx
Normal file
123
apps/web/app/(marketing)/faq/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'FAQ',
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: `Do you offer a free trial?`,
|
||||
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
|
||||
},
|
||||
{
|
||||
question: `Can I cancel my subscription?`,
|
||||
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Where can I find my invoices?`,
|
||||
answer: `You can find your invoices in your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `What payment methods do you accept?`,
|
||||
answer: `We accept all major credit cards and PayPal.`,
|
||||
},
|
||||
{
|
||||
question: `Can I upgrade or downgrade my plan?`,
|
||||
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Do you offer discounts for non-profits?`,
|
||||
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
|
||||
},
|
||||
];
|
||||
|
||||
const FAQPage = () => {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => {
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<script
|
||||
key={'ld:json'}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'FAQ'}
|
||||
subtitle={'Frequently Asked Questions'}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'm-auto flex w-full max-w-xl items-center justify-center'
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{faqItems.map((item, index) => {
|
||||
return <FaqItem key={index} item={item} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withI18n(FAQPage);
|
||||
|
||||
function FaqItem({
|
||||
item,
|
||||
}: React.PropsWithChildren<{
|
||||
item: {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
}>) {
|
||||
return (
|
||||
<details className={'group border-b px-2 py-4'}>
|
||||
<summary
|
||||
className={
|
||||
'flex items-center justify-between hover:cursor-pointer hover:underline'
|
||||
}
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
'hover:underline-none cursor-pointer font-sans text-lg font-medium'
|
||||
}
|
||||
>
|
||||
{item.question}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<ChevronDownIcon
|
||||
className={'h-5 transition duration-300 group-open:-rotate-180'}
|
||||
/>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div
|
||||
className={'flex flex-col space-y-2 py-1 text-muted-foreground'}
|
||||
dangerouslySetInnerHTML={{ __html: item.answer }}
|
||||
/>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/(marketing)/layout.tsx
Normal file
18
apps/web/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SiteFooter } from './components/site-footer';
|
||||
import { SiteHeader } from './components/site-header';
|
||||
|
||||
function SiteLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
{props.children}
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(SiteLayout);
|
||||
3
apps/web/app/(marketing)/loading.tsx
Normal file
3
apps/web/app/(marketing)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
285
apps/web/app/(marketing)/page.tsx
Normal file
285
apps/web/app/(marketing)/page.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-16'}>
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'my-12 flex flex-col items-center md:flex-row lg:my-16' +
|
||||
' mx-auto flex-1 justify-center animate-in fade-in ' +
|
||||
' duration-1000 slide-in-from-top-12'
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-1 flex-col items-center space-y-8'}>
|
||||
<Pill>
|
||||
<span>The leading SaaS Starter Kit for ambitious developers</span>
|
||||
</Pill>
|
||||
|
||||
<HeroTitle>
|
||||
<span>The SaaS Solution for</span>
|
||||
<span>developers and founders</span>
|
||||
</HeroTitle>
|
||||
|
||||
<div>
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>Here you can write a short description of your SaaS</span>
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>
|
||||
This subheading is usually laid out on multiple lines
|
||||
</span>
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
level={3}
|
||||
className={'text-center font-medium text-muted-foreground'}
|
||||
>
|
||||
<span>Impress your customers, straight to the point.</span>
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<MainCallToActionButton />
|
||||
|
||||
<span className={'text-xs text-muted-foreground'}>
|
||||
Free plan. No credit card required.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mx-auto flex max-w-5xl justify-center py-12 animate-in fade-in ' +
|
||||
' delay-300 duration-1000 slide-in-from-top-16 fill-mode-both'
|
||||
}
|
||||
>
|
||||
<Image
|
||||
priority
|
||||
className={
|
||||
'rounded-2xl' +
|
||||
' shadow-primary/40 animate-in fade-in' +
|
||||
' delay-300 duration-1000 ease-out zoom-in-50 fill-mode-both'
|
||||
}
|
||||
width={2688}
|
||||
height={1824}
|
||||
src={`/assets/images/dashboard-dark.webp`}
|
||||
alt={`App Image`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-24 py-16'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex max-w-3xl flex-col items-center space-y-8 text-center'
|
||||
}
|
||||
>
|
||||
<Pill>
|
||||
<span>A modern, scalable, and secure SaaS Starter Kit</span>
|
||||
</Pill>
|
||||
|
||||
<div className={'flex flex-col space-y-2.5'}>
|
||||
<Heading level={2}>The best tool in the space</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Unbeatable Features and Benefits for Your SaaS Business
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FeatureShowcaseContainer>
|
||||
<LeftFeatureContainer>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={2}>Authentication</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Secure and Easy-to-Use Authentication for Your SaaS Website
|
||||
and API
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Our authentication system is built on top of the
|
||||
industry-leading PaaS such as Supabase and Firebase. It is
|
||||
secure, easy-to-use, and fully customizable. It supports
|
||||
email/password, social logins, and more.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant={'outline'}>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>Get Started</span>
|
||||
<ChevronRightIcon className={'h-3'} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</LeftFeatureContainer>
|
||||
|
||||
<RightFeatureContainer>
|
||||
<Image
|
||||
className="rounded-2xl"
|
||||
src={'/assets/images/sign-in.webp'}
|
||||
width={'626'}
|
||||
height={'683'}
|
||||
alt={'Sign In'}
|
||||
/>
|
||||
</RightFeatureContainer>
|
||||
</FeatureShowcaseContainer>
|
||||
|
||||
<FeatureShowcaseContainer>
|
||||
<LeftFeatureContainer>
|
||||
<Image
|
||||
className="rounded-2xl"
|
||||
src={'/assets/images/dashboard.webp'}
|
||||
width={'887'}
|
||||
height={'743'}
|
||||
alt={'Dashboard'}
|
||||
/>
|
||||
</LeftFeatureContainer>
|
||||
|
||||
<RightFeatureContainer>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={1}>Dashboard</Heading>
|
||||
|
||||
<Heading level={2} className={'text-muted-foreground'}>
|
||||
A fantastic dashboard to manage your SaaS business
|
||||
</Heading>
|
||||
|
||||
<div>
|
||||
Our dashboard offers an overview of your SaaS business. It
|
||||
shows at a glance all you need to know about your business. It
|
||||
is fully customizable and extendable.
|
||||
</div>
|
||||
</div>
|
||||
</RightFeatureContainer>
|
||||
</FeatureShowcaseContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-16 py-16'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center space-y-8 text-center'}>
|
||||
<Pill>
|
||||
Get started for free. No credit card required. Cancel anytime.
|
||||
</Pill>
|
||||
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Heading level={2}>
|
||||
Ready to take your SaaS business to the next level?
|
||||
</Heading>
|
||||
|
||||
<Heading level={3} className={'text-muted-foreground'}>
|
||||
Get started on our free plan and upgrade when you are ready.
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'w-full'}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(Home);
|
||||
|
||||
function HeroTitle({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<h1
|
||||
className={
|
||||
'text-center text-4xl md:text-5xl' +
|
||||
' font-heading flex flex-col font-bold xl:text-7xl'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<h2
|
||||
className={
|
||||
'inline-flex w-auto items-center space-x-2' +
|
||||
' rounded-full bg-gradient-to-br dark:from-gray-200 dark:via-gray-400' +
|
||||
' bg-clip-text px-4 py-2 text-center text-sm dark:to-gray-700' +
|
||||
' border font-normal text-muted-foreground shadow-sm dark:text-transparent'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureShowcaseContainer(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-between lg:flex-row' +
|
||||
' lg:space-x-24'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftFeatureContainer(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-8 lg:w-6/12'}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RightFeatureContainer(props: React.PropsWithChildren) {
|
||||
return <div className={'flex w-full lg:w-6/12'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
function MainCallToActionButton() {
|
||||
return (
|
||||
<Button className={'rounded-full'}>
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<span>Get Started</span>
|
||||
|
||||
<ChevronRightIcon
|
||||
className={
|
||||
'h-4 animate-in fade-in slide-in-from-left-8' +
|
||||
' delay-1000 duration-1000 zoom-in fill-mode-both'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/(marketing)/pricing/page.tsx
Normal file
28
apps/web/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PricingTable } from '@kit/billing/components/pricing-table';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Pricing',
|
||||
};
|
||||
|
||||
function PricingPage() {
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'my-8 flex flex-col space-y-16'}>
|
||||
<SitePageHeader
|
||||
title={'Pricing'}
|
||||
subtitle={'Our pricing is designed to scale with your business.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PricingTable paths={pathsConfig.auth} config={billingConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PricingPage);
|
||||
Reference in New Issue
Block a user