Revert "Unify workspace dropdowns; Update layouts (#458)"
This reverts commit 4bc8448a1d.
This commit is contained in:
30
apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx
Normal file
30
apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:cookiePolicy'),
|
||||
};
|
||||
}
|
||||
|
||||
async function CookiePolicyPage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
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 withI18n(CookiePolicyPage);
|
||||
30
apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx
Normal file
30
apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:privacyPolicy'),
|
||||
};
|
||||
}
|
||||
|
||||
async function PrivacyPolicyPage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader
|
||||
title={t('marketing:privacyPolicy')}
|
||||
subtitle={t('marketing:privacyPolicyDescription')}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto py-8'}>
|
||||
<div>Your terms of service content here</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PrivacyPolicyPage);
|
||||
30
apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx
Normal file
30
apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:termsOfService'),
|
||||
};
|
||||
}
|
||||
|
||||
async function TermsOfServicePage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
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 withI18n(TermsOfServicePage);
|
||||
58
apps/web/app/(marketing)/_components/site-footer.tsx
Normal file
58
apps/web/app/(marketing)/_components/site-footer.tsx
Normal 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" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'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,
|
||||
};
|
||||
|
||||
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
|
||||
className={'hidden md:flex md:text-sm'}
|
||||
asChild
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
>
|
||||
<Link href={pathsConfig.auth.signIn}>
|
||||
<Trans i18nKey={'auth:signIn'} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="text-xs md:text-sm"
|
||||
variant={'default'}
|
||||
size={'sm'}
|
||||
>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/web/app/(marketing)/_components/site-header.tsx
Normal file
17
apps/web/app/(marketing)/_components/site-header.tsx
Normal 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 />}
|
||||
navigation={<SiteNavigation />}
|
||||
actions={<SiteHeaderAccountSection user={props.user ?? null} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
87
apps/web/app/(marketing)/_components/site-navigation.tsx
Normal file
87
apps/web/app/(marketing)/_components/site-navigation.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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} asChild>
|
||||
<Link className={className} href={item.path}>
|
||||
<Trans i18nKey={item.label} />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
47
apps/web/app/(marketing)/_components/site-page-header.tsx
Normal file
47
apps/web/app/(marketing)/_components/site-page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
78
apps/web/app/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Post } from '../../blog/_components/post';
|
||||
|
||||
interface BlogPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const getPostBySlug = cache(postLoader);
|
||||
|
||||
async function postLoader(slug: string) {
|
||||
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 withI18n(BlogPost);
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
24
apps/web/app/(marketing)/blog/_components/cover-image.tsx
Normal file
24
apps/web/app/(marketing)/blog/_components/cover-image.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
50
apps/web/app/(marketing)/blog/_components/post-header.tsx
Normal file
50
apps/web/app/(marketing)/blog/_components/post-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/web/app/(marketing)/blog/_components/post-preview.tsx
Normal file
68
apps/web/app/(marketing)/blog/_components/post-preview.tsx
Normal 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;
|
||||
}
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
122
apps/web/app/(marketing)/blog/page.tsx
Normal file
122
apps/web/app/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// 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, resolvedLanguage } = await createI18nServerInstance();
|
||||
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('marketing:blog'),
|
||||
description: t('marketing: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, resolvedLanguage: language } = await createI18nServerInstance();
|
||||
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('marketing:blog')}
|
||||
subtitle={t('marketing: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 withI18n(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>
|
||||
);
|
||||
}
|
||||
110
apps/web/app/(marketing)/changelog/[slug]/page.tsx
Normal file
110
apps/web/app/(marketing)/changelog/[slug]/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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 withI18n(ChangelogEntryPage);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 asChild variant="outline" size="sm">
|
||||
<Link href={`/changelog?page=${previousPage}`}>
|
||||
<ArrowLeft className="mr-2 h-3 w-3" />
|
||||
<span>
|
||||
<Trans i18nKey="marketing:changelogPaginationPrevious" />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canGoToNextPage && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/changelog?page=${nextPage}`}>
|
||||
<span>
|
||||
<Trans i18nKey="marketing:changelogPaginationNext" />
|
||||
</span>
|
||||
<ArrowRight className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
117
apps/web/app/(marketing)/changelog/page.tsx
Normal file
117
apps/web/app/(marketing)/changelog/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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, resolvedLanguage } = await createI18nServerInstance();
|
||||
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('marketing:changelog'),
|
||||
description: t('marketing: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, resolvedLanguage: language } = await createI18nServerInstance();
|
||||
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('marketing:changelog')}
|
||||
subtitle={t('marketing: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 withI18n(ChangelogPage);
|
||||
161
apps/web/app/(marketing)/contact/_components/contact-form.tsx
Normal file
161
apps/web/app/(marketing)/contact/_components/contact-form.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
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 [pending, startTransition] = useTransition();
|
||||
|
||||
const [state, setState] = useState({
|
||||
success: false,
|
||||
error: 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) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await sendContactEmail(data);
|
||||
|
||||
setState({ success: true, error: false });
|
||||
} catch {
|
||||
setState({ error: true, success: false });
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<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={pending} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { 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),
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
|
||||
import { ContactEmailSchema } from '../contact-email.schema';
|
||||
|
||||
const contactEmail = z
|
||||
.string({
|
||||
description: `The email where you want to receive the contact form submissions.`,
|
||||
required_error:
|
||||
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||
})
|
||||
.parse(process.env.CONTACT_EMAIL);
|
||||
|
||||
const emailFrom = z
|
||||
.string({
|
||||
description: `The email sending address.`,
|
||||
required_error:
|
||||
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||
})
|
||||
.parse(process.env.EMAIL_SENDER);
|
||||
|
||||
export const sendContactEmail = enhanceAction(
|
||||
async (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 {};
|
||||
},
|
||||
{
|
||||
schema: ContactEmailSchema,
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
54
apps/web/app/(marketing)/contact/page.tsx
Normal file
54
apps/web/app/(marketing)/contact/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:contact'),
|
||||
};
|
||||
}
|
||||
|
||||
async function ContactPage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader
|
||||
title={t(`marketing:contact`)}
|
||||
subtitle={t(`marketing: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 withI18n(ContactPage);
|
||||
94
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
94
apps/web/app/(marketing)/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// 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 withI18n(DocumentationPage);
|
||||
32
apps/web/app/(marketing)/docs/_components/docs-card.tsx
Normal file
32
apps/web/app/(marketing)/docs/_components/docs-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/(marketing)/docs/_components/docs-cards.tsx
Normal file
24
apps/web/app/(marketing)/docs/_components/docs-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx
Normal file
32
apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-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, true);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrent}
|
||||
className={cn('text-secondary-foreground transition-all')}
|
||||
>
|
||||
<Link href={url}>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
|
||||
{children}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -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, false),
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('group/collapsible', {
|
||||
'group/active': isChildActive,
|
||||
})}
|
||||
defaultOpen={isChildActive ? true : !props.node.collapsed}
|
||||
>
|
||||
{props.children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
167
apps/web/app/(marketing)/docs/_components/docs-navigation.tsx
Normal file
167
apps/web/app/(marketing)/docs/_components/docs-navigation.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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/shadcn-sidebar';
|
||||
|
||||
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
|
||||
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
|
||||
|
||||
import { FloatingDocumentationNavigation } from './floating-docs-navigation';
|
||||
|
||||
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 asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
{label}
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</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={'ghost'}
|
||||
className={
|
||||
'border-border/50 sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'
|
||||
}
|
||||
>
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className={'pb-48'}>
|
||||
<Tree pages={pages} level={0} prefix={prefix} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
|
||||
<div className={'lg:hidden'}>
|
||||
<FloatingDocumentationNavigation>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<Tree pages={pages} level={0} prefix={prefix} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</FloatingDocumentationNavigation>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
export function FloatingDocumentationNavigation(
|
||||
props: React.PropsWithChildren,
|
||||
) {
|
||||
const activePath = usePathname();
|
||||
|
||||
const body = useMemo(() => {
|
||||
return isBrowser() ? document.body : null;
|
||||
}, []);
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const enableScrolling = useEffectEvent(
|
||||
() => body && (body.style.overflowY = ''),
|
||||
);
|
||||
|
||||
const disableScrolling = useEffectEvent(
|
||||
() => body && (body.style.overflowY = 'hidden'),
|
||||
);
|
||||
|
||||
// enable/disable body scrolling when the docs are toggled
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
disableScrolling();
|
||||
} else {
|
||||
enableScrolling();
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// hide docs when navigating to another page
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsVisible(false);
|
||||
}, [activePath]);
|
||||
|
||||
const onClick = () => {
|
||||
setIsVisible(!isVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={isVisible}>
|
||||
<div
|
||||
className={
|
||||
'fixed top-0 left-0 z-10 h-screen w-full p-4' +
|
||||
' dark:bg-background flex flex-col space-y-4 overflow-auto bg-white'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<Button
|
||||
className={'fixed right-5 bottom-5 z-10 h-16 w-16 rounded-full'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Menu className={'h-8'} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts
Normal file
31
apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
33
apps/web/app/(marketing)/docs/_lib/utils.ts
Normal file
33
apps/web/app/(marketing)/docs/_lib/utils.ts
Normal 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);
|
||||
}
|
||||
45
apps/web/app/(marketing)/docs/layout.tsx
Normal file
45
apps/web/app/(marketing)/docs/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
|
||||
// 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 { resolvedLanguage } = await createI18nServerInstance();
|
||||
const docs = await getDocs(resolvedLanguage);
|
||||
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;
|
||||
37
apps/web/app/(marketing)/docs/page.tsx
Normal file
37
apps/web/app/(marketing)/docs/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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 createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:documentation'),
|
||||
};
|
||||
};
|
||||
|
||||
async function DocsPage() {
|
||||
const { t, resolvedLanguage } = await createI18nServerInstance();
|
||||
const items = await getDocs(resolvedLanguage);
|
||||
|
||||
// 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('marketing:documentation')}
|
||||
subtitle={t('marketing:documentationSubtitle')}
|
||||
/>
|
||||
|
||||
<div className={'relative flex size-full justify-center overflow-y-auto'}>
|
||||
<DocsCards cards={cards} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(DocsPage);
|
||||
141
apps/web/app/(marketing)/faq/page.tsx
Normal file
141
apps/web/app/(marketing)/faq/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:faq'),
|
||||
};
|
||||
};
|
||||
|
||||
async function FAQPage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
// replace this content with translations
|
||||
const faqItems = [
|
||||
{
|
||||
// or: t('marketing:faq.question1')
|
||||
question: `Do you offer a free trial?`,
|
||||
// or: t('marketing: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('marketing:faq')}
|
||||
subtitle={t('marketing: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 asChild variant={'outline'}>
|
||||
<Link href={'/contact'}>
|
||||
<span>
|
||||
<Trans i18nKey={'marketing:contactFaq'} />
|
||||
</span>
|
||||
|
||||
<ArrowRight className={'ml-2 w-4'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(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>
|
||||
);
|
||||
}
|
||||
23
apps/web/app/(marketing)/layout.tsx
Normal file
23
apps/web/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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 withI18n(SiteLayout);
|
||||
202
apps/web/app/(marketing)/page.tsx
Normal file
202
apps/web/app/(marketing)/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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 asChild>
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<ArrowRightIcon className={'h-4 w-4'} />
|
||||
</Link>
|
||||
</PillActionButton>
|
||||
</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 withI18n(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>
|
||||
);
|
||||
}
|
||||
39
apps/web/app/(marketing)/pricing/page.tsx
Normal file
39
apps/web/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:pricing'),
|
||||
};
|
||||
};
|
||||
|
||||
const paths = {
|
||||
signUp: pathsConfig.auth.signUp,
|
||||
return: pathsConfig.app.home,
|
||||
};
|
||||
|
||||
async function PricingPage() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<SitePageHeader
|
||||
title={t('marketing:pricing')}
|
||||
subtitle={t('marketing:pricingSubtitle')}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
||||
<PricingTable paths={paths} config={billingConfig} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(PricingPage);
|
||||
Reference in New Issue
Block a user